cobolx 1.0.3 → 1.0.4
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/agent/client.rs +107 -8
- package/src/agent/db_agent.rs +71 -23
- package/src/agent/explain_agent.rs +53 -22
- package/src/agent/fs_agent.rs +211 -83
- package/src/agent/skills.rs +336 -0
- package/src/agent/types.rs +7 -0
- package/src/agent.rs +1 -0
- package/src/cobol/indexer.rs +375 -5
- package/src/cobol/model.rs +78 -0
- package/src/cobol/scanner.rs +2 -0
- package/src/cobol/source_parser.rs +341 -2
- package/src/lib.rs +1 -0
- package/src/main.rs +1 -0
- package/src/memory/memories.rs +208 -0
- package/src/memory/runs.rs +161 -0
- package/src/memory/store.rs +120 -0
- package/src/memory.rs +8 -2
- package/src/path_safety.rs +280 -0
- package/src/ui/draw.rs +1 -0
- package/src/ui/tui.rs +239 -0
- package/tests/indexer_tests.rs +261 -0
- package/tests/project_files_tests.rs +23 -51
- package/src/memory/files.rs +0 -155
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::error::Error;
|
|
3
|
+
use std::fs::OpenOptions;
|
|
4
|
+
use std::io::Write;
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
|
|
7
|
+
type RunResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
|
8
|
+
|
|
9
|
+
/// Append-only session log: `.cobolx/runs/{run_id}/run.md`
|
|
10
|
+
pub struct RunJournal {
|
|
11
|
+
run_id: String,
|
|
12
|
+
started_at: String,
|
|
13
|
+
run_dir: PathBuf,
|
|
14
|
+
markdown_path: PathBuf,
|
|
15
|
+
seq: u64,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl RunJournal {
|
|
19
|
+
pub fn start(runs_dir: &Path) -> RunResult<Self> {
|
|
20
|
+
let started_at = chrono::Utc::now().to_rfc3339();
|
|
21
|
+
let run_id = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
|
|
22
|
+
let run_dir = runs_dir.join(&run_id);
|
|
23
|
+
std::fs::create_dir_all(&run_dir)?;
|
|
24
|
+
|
|
25
|
+
let markdown_path = run_dir.join("run.md");
|
|
26
|
+
let header = format!(
|
|
27
|
+
"# COBOLX Run {run_id}\n\n\
|
|
28
|
+
- started_at: {started_at}\n\
|
|
29
|
+
- status: running\n\n\
|
|
30
|
+
---\n"
|
|
31
|
+
);
|
|
32
|
+
std::fs::write(&markdown_path, header)?;
|
|
33
|
+
|
|
34
|
+
Ok(Self {
|
|
35
|
+
run_id,
|
|
36
|
+
started_at,
|
|
37
|
+
run_dir,
|
|
38
|
+
markdown_path,
|
|
39
|
+
seq: 0,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn run_id(&self) -> &str {
|
|
44
|
+
&self.run_id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pub fn run_dir(&self) -> &Path {
|
|
48
|
+
&self.run_dir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pub fn read_log(&self) -> RunResult<String> {
|
|
52
|
+
Ok(std::fs::read_to_string(&self.markdown_path)?)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Records one operation; failures are ignored so logging never breaks the UI.
|
|
56
|
+
pub fn log(&mut self, kind: &str, payload: Value) {
|
|
57
|
+
self.seq += 1;
|
|
58
|
+
let ts = chrono::Utc::now().to_rfc3339();
|
|
59
|
+
let body = format_payload_md(&payload);
|
|
60
|
+
let section = format!(
|
|
61
|
+
"\n### {kind} — {ts} (seq {seq})\n\n{body}\n",
|
|
62
|
+
kind = kind,
|
|
63
|
+
ts = ts,
|
|
64
|
+
seq = self.seq,
|
|
65
|
+
body = body
|
|
66
|
+
);
|
|
67
|
+
if let Ok(mut file) = OpenOptions::new()
|
|
68
|
+
.create(true)
|
|
69
|
+
.append(true)
|
|
70
|
+
.open(&self.markdown_path)
|
|
71
|
+
{
|
|
72
|
+
let _ = write!(file, "{}", section);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pub fn finish(&self, status: &str) {
|
|
77
|
+
let footer = format!(
|
|
78
|
+
"\n---\n\n\
|
|
79
|
+
- finished_at: {}\n\
|
|
80
|
+
- status: {}\n\
|
|
81
|
+
- event_count: {}\n",
|
|
82
|
+
chrono::Utc::now().to_rfc3339(),
|
|
83
|
+
status,
|
|
84
|
+
self.seq
|
|
85
|
+
);
|
|
86
|
+
if let Ok(mut file) = OpenOptions::new()
|
|
87
|
+
.create(true)
|
|
88
|
+
.append(true)
|
|
89
|
+
.open(&self.markdown_path)
|
|
90
|
+
{
|
|
91
|
+
let _ = write!(file, "{}", footer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn format_payload_md(payload: &Value) -> String {
|
|
97
|
+
match payload {
|
|
98
|
+
Value::Object(map) if !map.is_empty() => {
|
|
99
|
+
let mut lines = Vec::with_capacity(map.len());
|
|
100
|
+
for (key, value) in map {
|
|
101
|
+
lines.push(format!("- {}: {}", key, format_value_md(value)));
|
|
102
|
+
}
|
|
103
|
+
lines.join("\n")
|
|
104
|
+
}
|
|
105
|
+
other => format_value_md(other),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fn format_value_md(value: &Value) -> String {
|
|
110
|
+
match value {
|
|
111
|
+
Value::Null => "null".to_string(),
|
|
112
|
+
Value::Bool(b) => b.to_string(),
|
|
113
|
+
Value::Number(n) => n.to_string(),
|
|
114
|
+
Value::String(s) => {
|
|
115
|
+
if s.contains('\n') {
|
|
116
|
+
format!("```\n{}\n```", s)
|
|
117
|
+
} else {
|
|
118
|
+
s.clone()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
Value::Array(items) => {
|
|
122
|
+
if items.is_empty() {
|
|
123
|
+
"[]".to_string()
|
|
124
|
+
} else {
|
|
125
|
+
items
|
|
126
|
+
.iter()
|
|
127
|
+
.map(format_value_md)
|
|
128
|
+
.collect::<Vec<_>>()
|
|
129
|
+
.join(", ")
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
Value::Object(_) => value.to_string(),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[cfg(test)]
|
|
137
|
+
mod tests {
|
|
138
|
+
use super::*;
|
|
139
|
+
use serde_json::json;
|
|
140
|
+
use tempfile::tempdir;
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn run_journal_appends_markdown_sections() {
|
|
144
|
+
let dir = tempdir().unwrap();
|
|
145
|
+
let runs_dir = dir.path().join("runs");
|
|
146
|
+
let mut journal = RunJournal::start(&runs_dir).unwrap();
|
|
147
|
+
|
|
148
|
+
journal.log("user_message", json!({ "text": "hello" }));
|
|
149
|
+
journal.log("route_selected", json!({ "route": "DATABASE" }));
|
|
150
|
+
journal.finish("completed");
|
|
151
|
+
|
|
152
|
+
let md = std::fs::read_to_string(journal.run_dir().join("run.md")).unwrap();
|
|
153
|
+
assert!(md.contains("# COBOLX Run"));
|
|
154
|
+
assert!(md.contains("### user_message"));
|
|
155
|
+
assert!(md.contains("- text: hello"));
|
|
156
|
+
assert!(md.contains("### route_selected"));
|
|
157
|
+
assert!(md.contains("- route: DATABASE"));
|
|
158
|
+
assert!(md.contains("- status: completed"));
|
|
159
|
+
assert!(md.contains("- event_count: 2"));
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/memory/store.rs
CHANGED
|
@@ -2,6 +2,8 @@ use rusqlite::{Connection, OpenFlags};
|
|
|
2
2
|
use std::error::Error;
|
|
3
3
|
use std::path::{Path, PathBuf};
|
|
4
4
|
|
|
5
|
+
use super::memories::CodexMemories;
|
|
6
|
+
|
|
5
7
|
type StoreResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
|
6
8
|
|
|
7
9
|
#[allow(dead_code)]
|
|
@@ -75,6 +77,14 @@ impl MemoryStore {
|
|
|
75
77
|
&self.paths.skills_dir
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
pub fn runs_dir(&self) -> &Path {
|
|
81
|
+
&self.paths.runs_dir
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pub fn codex_memories(&self) -> CodexMemories {
|
|
85
|
+
CodexMemories::for_project(&self.paths.base_dir, &self.paths.root)
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
pub fn connection(&self) -> &Connection {
|
|
79
89
|
&self.conn
|
|
80
90
|
}
|
|
@@ -133,6 +143,13 @@ impl MemoryStore {
|
|
|
133
143
|
|
|
134
144
|
Ok(serde_json::Value::Array(result_rows))
|
|
135
145
|
}
|
|
146
|
+
|
|
147
|
+
pub fn project_index_is_empty(&self) -> StoreResult<bool> {
|
|
148
|
+
let file_count: i64 = self
|
|
149
|
+
.conn
|
|
150
|
+
.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
|
|
151
|
+
Ok(file_count == 0)
|
|
152
|
+
}
|
|
136
153
|
}
|
|
137
154
|
|
|
138
155
|
fn create_dirs(paths: &MemoryPaths) -> StoreResult<()> {
|
|
@@ -141,6 +158,11 @@ fn create_dirs(paths: &MemoryPaths) -> StoreResult<()> {
|
|
|
141
158
|
std::fs::create_dir_all(&paths.docs_dir)?;
|
|
142
159
|
std::fs::create_dir_all(&paths.runs_dir)?;
|
|
143
160
|
std::fs::create_dir_all(&paths.skills_dir)?;
|
|
161
|
+
for dir in crate::agent::skills::AGENT_SKILL_DIRS {
|
|
162
|
+
std::fs::create_dir_all(paths.skills_dir.join(dir))?;
|
|
163
|
+
}
|
|
164
|
+
let memories = CodexMemories::for_project(&paths.base_dir, &paths.root);
|
|
165
|
+
memories.ensure_layout()?;
|
|
144
166
|
Ok(())
|
|
145
167
|
}
|
|
146
168
|
|
|
@@ -238,6 +260,104 @@ fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
|
|
|
238
260
|
CREATE INDEX IF NOT EXISTS idx_data_items_source_file ON data_items(source_file_id);
|
|
239
261
|
CREATE INDEX IF NOT EXISTS idx_data_items_name ON data_items(name);
|
|
240
262
|
|
|
263
|
+
CREATE TABLE IF NOT EXISTS program_features (
|
|
264
|
+
program_id INTEGER PRIMARY KEY,
|
|
265
|
+
source_file_id INTEGER NOT NULL,
|
|
266
|
+
incoming_call_count INTEGER NOT NULL DEFAULT 0,
|
|
267
|
+
outgoing_call_count INTEGER NOT NULL DEFAULT 0,
|
|
268
|
+
static_call_count INTEGER NOT NULL DEFAULT 0,
|
|
269
|
+
dynamic_call_count INTEGER NOT NULL DEFAULT 0,
|
|
270
|
+
copybook_use_count INTEGER NOT NULL DEFAULT 0,
|
|
271
|
+
distinct_copybook_count INTEGER NOT NULL DEFAULT 0,
|
|
272
|
+
referenced_by_file_count INTEGER NOT NULL DEFAULT 0,
|
|
273
|
+
is_entrypoint INTEGER NOT NULL DEFAULT 0,
|
|
274
|
+
has_heavy_copy_usage INTEGER NOT NULL DEFAULT 0,
|
|
275
|
+
data_item_count INTEGER NOT NULL DEFAULT 0,
|
|
276
|
+
paragraph_count INTEGER NOT NULL DEFAULT 0,
|
|
277
|
+
external_op_count INTEGER NOT NULL DEFAULT 0,
|
|
278
|
+
identifier_count INTEGER NOT NULL DEFAULT 0,
|
|
279
|
+
literal_count INTEGER NOT NULL DEFAULT 0,
|
|
280
|
+
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE,
|
|
281
|
+
FOREIGN KEY(source_file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
CREATE TABLE IF NOT EXISTS code_blocks (
|
|
285
|
+
id INTEGER PRIMARY KEY,
|
|
286
|
+
program_id INTEGER NOT NULL,
|
|
287
|
+
source_file_id INTEGER NOT NULL,
|
|
288
|
+
name TEXT NOT NULL,
|
|
289
|
+
kind TEXT NOT NULL,
|
|
290
|
+
parent_section TEXT,
|
|
291
|
+
sequence_no INTEGER NOT NULL,
|
|
292
|
+
statement_count INTEGER NOT NULL DEFAULT 0,
|
|
293
|
+
start_offset INTEGER NOT NULL,
|
|
294
|
+
byte_len INTEGER NOT NULL,
|
|
295
|
+
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE,
|
|
296
|
+
FOREIGN KEY(source_file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
CREATE INDEX IF NOT EXISTS idx_code_blocks_program ON code_blocks(program_id);
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_code_blocks_kind ON code_blocks(kind);
|
|
301
|
+
|
|
302
|
+
CREATE TABLE IF NOT EXISTS external_ops (
|
|
303
|
+
id INTEGER PRIMARY KEY,
|
|
304
|
+
program_id INTEGER NOT NULL,
|
|
305
|
+
source_file_id INTEGER NOT NULL,
|
|
306
|
+
kind TEXT NOT NULL,
|
|
307
|
+
verb TEXT NOT NULL,
|
|
308
|
+
target TEXT,
|
|
309
|
+
start_offset INTEGER NOT NULL,
|
|
310
|
+
byte_len INTEGER NOT NULL,
|
|
311
|
+
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE,
|
|
312
|
+
FOREIGN KEY(source_file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
CREATE INDEX IF NOT EXISTS idx_external_ops_program ON external_ops(program_id);
|
|
316
|
+
CREATE INDEX IF NOT EXISTS idx_external_ops_kind ON external_ops(kind);
|
|
317
|
+
CREATE INDEX IF NOT EXISTS idx_external_ops_target ON external_ops(target);
|
|
318
|
+
|
|
319
|
+
CREATE TABLE IF NOT EXISTS identifiers (
|
|
320
|
+
id INTEGER PRIMARY KEY,
|
|
321
|
+
program_id INTEGER NOT NULL,
|
|
322
|
+
source_file_id INTEGER NOT NULL,
|
|
323
|
+
kind TEXT NOT NULL,
|
|
324
|
+
value TEXT NOT NULL,
|
|
325
|
+
occurrences INTEGER NOT NULL DEFAULT 1,
|
|
326
|
+
first_offset INTEGER NOT NULL,
|
|
327
|
+
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE,
|
|
328
|
+
FOREIGN KEY(source_file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
CREATE INDEX IF NOT EXISTS idx_identifiers_program ON identifiers(program_id);
|
|
332
|
+
CREATE INDEX IF NOT EXISTS idx_identifiers_kind_value ON identifiers(kind, value);
|
|
333
|
+
|
|
334
|
+
CREATE TABLE IF NOT EXISTS literals (
|
|
335
|
+
id INTEGER PRIMARY KEY,
|
|
336
|
+
program_id INTEGER NOT NULL,
|
|
337
|
+
source_file_id INTEGER NOT NULL,
|
|
338
|
+
kind TEXT NOT NULL,
|
|
339
|
+
value TEXT NOT NULL,
|
|
340
|
+
occurrences INTEGER NOT NULL DEFAULT 1,
|
|
341
|
+
first_offset INTEGER NOT NULL,
|
|
342
|
+
FOREIGN KEY(program_id) REFERENCES programs(id) ON DELETE CASCADE,
|
|
343
|
+
FOREIGN KEY(source_file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
CREATE INDEX IF NOT EXISTS idx_literals_program ON literals(program_id);
|
|
347
|
+
CREATE INDEX IF NOT EXISTS idx_literals_kind_value ON literals(kind, value);
|
|
348
|
+
|
|
349
|
+
CREATE TABLE IF NOT EXISTS copybook_features (
|
|
350
|
+
copybook_file_id INTEGER PRIMARY KEY,
|
|
351
|
+
copybook_name TEXT NOT NULL,
|
|
352
|
+
used_by_program_count INTEGER NOT NULL DEFAULT 0,
|
|
353
|
+
used_by_file_count INTEGER NOT NULL DEFAULT 0,
|
|
354
|
+
replacing_use_count INTEGER NOT NULL DEFAULT 0,
|
|
355
|
+
data_item_count INTEGER NOT NULL DEFAULT 0,
|
|
356
|
+
contains_header_fields INTEGER NOT NULL DEFAULT 0,
|
|
357
|
+
contains_error_fields INTEGER NOT NULL DEFAULT 0,
|
|
358
|
+
FOREIGN KEY(copybook_file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
359
|
+
);
|
|
360
|
+
|
|
241
361
|
CREATE TABLE IF NOT EXISTS runs (
|
|
242
362
|
id TEXT PRIMARY KEY,
|
|
243
363
|
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
package/src/memory.rs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
pub mod
|
|
1
|
+
pub mod memories;
|
|
2
|
+
pub mod runs;
|
|
2
3
|
pub mod store;
|
|
3
4
|
|
|
4
5
|
#[allow(unused_imports)]
|
|
5
|
-
pub use
|
|
6
|
+
pub use memories::{
|
|
7
|
+
CodexMemories, MEMORY_HANDBOOK_FILE, MEMORY_SUMMARY_FILE, MEMORY_SUMMARY_INJECT_MAX_CHARS,
|
|
8
|
+
TOKEN_SUMMARY_THRESHOLD,
|
|
9
|
+
};
|
|
10
|
+
pub use runs::RunJournal;
|
|
11
|
+
pub use store::MemoryStore;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
/// Normalizes a relative path string: rejects `..`, absolute paths, and unsafe components.
|
|
4
|
+
pub fn normalize_relative_path(raw: &str) -> Result<String, String> {
|
|
5
|
+
if raw.is_empty() {
|
|
6
|
+
return Err("file path must not be empty".into());
|
|
7
|
+
}
|
|
8
|
+
if raw.as_bytes().contains(&0) {
|
|
9
|
+
return Err("file path must not contain NUL".into());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let normalized = raw.replace('\\', "/");
|
|
13
|
+
if normalized.starts_with('/') {
|
|
14
|
+
return Err("file path must be relative".into());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let mut parts = Vec::new();
|
|
18
|
+
for part in normalized.split('/') {
|
|
19
|
+
if part.is_empty() || part == "." {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if part == ".." {
|
|
23
|
+
return Err("file path must not contain parent components".into());
|
|
24
|
+
}
|
|
25
|
+
if has_windows_forbidden_chars(part) {
|
|
26
|
+
return Err("file path contains characters invalid on Windows".into());
|
|
27
|
+
}
|
|
28
|
+
if is_windows_reserved_name(part) {
|
|
29
|
+
return Err("file path contains a Windows reserved name".into());
|
|
30
|
+
}
|
|
31
|
+
parts.push(part);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if parts.is_empty() {
|
|
35
|
+
return Err("file path must not be empty".into());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Ok(parts.join("/"))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Validates `user_path` resolves inside `sandbox`. Returns the absolute path.
|
|
42
|
+
pub fn validate_sandbox_path(sandbox: &Path, user_path: &str) -> Result<PathBuf, String> {
|
|
43
|
+
let normalized = normalize_user_path(user_path)?;
|
|
44
|
+
let sandbox_canon = sandbox
|
|
45
|
+
.canonicalize()
|
|
46
|
+
.map_err(|e| format!("Sandbox path error: {e}"))?;
|
|
47
|
+
let candidate = build_sandbox_candidate(&sandbox_canon, &normalized)?;
|
|
48
|
+
let sandbox_canon_str = clean_canon(&sandbox_canon);
|
|
49
|
+
let candidate_str = clean_canon(&candidate);
|
|
50
|
+
|
|
51
|
+
if !is_subpath(&sandbox_canon_str, &candidate_str) {
|
|
52
|
+
return Err(format!(
|
|
53
|
+
"Access denied: '{}' is outside the sandbox directory",
|
|
54
|
+
user_path
|
|
55
|
+
));
|
|
56
|
+
}
|
|
57
|
+
Ok(candidate)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fn normalize_user_path(user_path: &str) -> Result<String, String> {
|
|
61
|
+
if Path::new(user_path).is_absolute() {
|
|
62
|
+
Ok(user_path.replace('\\', "/"))
|
|
63
|
+
} else {
|
|
64
|
+
normalize_relative_path(user_path.trim_start_matches(['/', '\\']))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn build_sandbox_candidate(sandbox_canon: &Path, normalized: &str) -> Result<PathBuf, String> {
|
|
69
|
+
if Path::new(normalized).is_absolute() {
|
|
70
|
+
let candidate = PathBuf::from(normalized);
|
|
71
|
+
let candidate_canon = candidate.canonicalize().unwrap_or(candidate);
|
|
72
|
+
Ok(candidate_canon)
|
|
73
|
+
} else {
|
|
74
|
+
let mut candidate = sandbox_canon.to_path_buf();
|
|
75
|
+
for part in normalized.split('/') {
|
|
76
|
+
if !part.is_empty() {
|
|
77
|
+
candidate.push(part);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
Ok(candidate)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Extra policy for write operations after sandbox resolution.
|
|
85
|
+
pub fn validate_write_path(sandbox: &Path, resolved: &Path) -> Result<(), String> {
|
|
86
|
+
let sandbox_canon = sandbox
|
|
87
|
+
.canonicalize()
|
|
88
|
+
.map_err(|e| format!("Sandbox path error: {e}"))?;
|
|
89
|
+
let sandbox_canon_str = clean_canon(&sandbox_canon);
|
|
90
|
+
let resolved_str = clean_canon(resolved);
|
|
91
|
+
|
|
92
|
+
if !is_subpath(&sandbox_canon_str, &resolved_str) {
|
|
93
|
+
return Err("Access denied: path is outside the sandbox directory".into());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let rel = relative_path_key(&sandbox_canon_str, &resolved_str);
|
|
97
|
+
|
|
98
|
+
if is_under_docs(&rel) && !has_markdown_extension(resolved) {
|
|
99
|
+
return Err("paths under docs/ must end with .md".into());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for part in rel.split('/') {
|
|
103
|
+
if part.is_empty() {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if has_windows_forbidden_chars(part) {
|
|
107
|
+
return Err("file path contains characters invalid on Windows".into());
|
|
108
|
+
}
|
|
109
|
+
if is_windows_reserved_name(part) {
|
|
110
|
+
return Err("file path contains a Windows reserved name".into());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Resolves and validates a sandbox write target.
|
|
118
|
+
pub fn validate_and_resolve_write(sandbox: &Path, user_path: &str) -> Result<PathBuf, String> {
|
|
119
|
+
let full_path = validate_sandbox_path(sandbox, user_path)?;
|
|
120
|
+
validate_write_path(sandbox, &full_path)?;
|
|
121
|
+
Ok(full_path)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Writes `content` to an already-validated absolute path.
|
|
125
|
+
pub fn write_validated_path(path: &Path, content: &str) -> Result<(), String> {
|
|
126
|
+
if let Some(parent) = path.parent() {
|
|
127
|
+
std::fs::create_dir_all(parent)
|
|
128
|
+
.map_err(|e| format!("Failed to create directories: {e}"))?;
|
|
129
|
+
}
|
|
130
|
+
std::fs::write(path, content).map_err(|e| format!("Failed to write file: {e}"))?;
|
|
131
|
+
Ok(())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Validates sandbox write policy and writes `content` to disk.
|
|
135
|
+
pub fn write_sandbox_file(
|
|
136
|
+
sandbox: &Path,
|
|
137
|
+
user_path: &str,
|
|
138
|
+
content: &str,
|
|
139
|
+
) -> Result<PathBuf, String> {
|
|
140
|
+
let full_path = validate_and_resolve_write(sandbox, user_path)?;
|
|
141
|
+
write_validated_path(&full_path, content)?;
|
|
142
|
+
Ok(full_path)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fn clean_canon(p: &Path) -> String {
|
|
146
|
+
let s = p.to_string_lossy().into_owned();
|
|
147
|
+
let s_stripped = if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
|
148
|
+
stripped.to_string()
|
|
149
|
+
} else {
|
|
150
|
+
s
|
|
151
|
+
};
|
|
152
|
+
s_stripped.replace('\\', "/").to_lowercase()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn is_subpath(base: &str, path: &str) -> bool {
|
|
156
|
+
path == base
|
|
157
|
+
|| (path.starts_with(base)
|
|
158
|
+
&& (base.ends_with('/') || path.chars().nth(base.chars().count()) == Some('/')))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fn relative_path_key(sandbox_canon_str: &str, resolved_str: &str) -> String {
|
|
162
|
+
if resolved_str == sandbox_canon_str {
|
|
163
|
+
return String::new();
|
|
164
|
+
}
|
|
165
|
+
let prefix_len = sandbox_canon_str.len();
|
|
166
|
+
if resolved_str.len() > prefix_len && resolved_str.as_bytes().get(prefix_len) == Some(&b'/') {
|
|
167
|
+
resolved_str[prefix_len + 1..].to_string()
|
|
168
|
+
} else {
|
|
169
|
+
String::new()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn is_under_docs(rel: &str) -> bool {
|
|
174
|
+
rel.is_empty() || rel == "docs" || rel.starts_with("docs/")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn has_markdown_extension(path: &Path) -> bool {
|
|
178
|
+
path.extension()
|
|
179
|
+
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn has_windows_forbidden_chars(part: &str) -> bool {
|
|
183
|
+
part.contains(':')
|
|
184
|
+
|| part
|
|
185
|
+
.chars()
|
|
186
|
+
.any(|c| matches!(c, '<' | '>' | '"' | '|' | '?' | '*'))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fn is_windows_reserved_name(part: &str) -> bool {
|
|
190
|
+
let stem = part
|
|
191
|
+
.split('.')
|
|
192
|
+
.next()
|
|
193
|
+
.unwrap_or(part)
|
|
194
|
+
.trim_end_matches(' ')
|
|
195
|
+
.to_ascii_uppercase();
|
|
196
|
+
|
|
197
|
+
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
|
198
|
+
|| stem
|
|
199
|
+
.strip_prefix("COM")
|
|
200
|
+
.and_then(|n| n.parse::<u8>().ok())
|
|
201
|
+
.is_some_and(|n| (1..=9).contains(&n))
|
|
202
|
+
|| stem
|
|
203
|
+
.strip_prefix("LPT")
|
|
204
|
+
.and_then(|n| n.parse::<u8>().ok())
|
|
205
|
+
.is_some_and(|n| (1..=9).contains(&n))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#[cfg(test)]
|
|
209
|
+
mod tests {
|
|
210
|
+
use super::*;
|
|
211
|
+
use tempfile::tempdir;
|
|
212
|
+
|
|
213
|
+
#[test]
|
|
214
|
+
fn validate_sandbox_path_allows_in_sandbox_and_rejects_escape() {
|
|
215
|
+
let dir = tempdir().unwrap();
|
|
216
|
+
let sandbox = dir.path();
|
|
217
|
+
|
|
218
|
+
assert!(validate_sandbox_path(sandbox, "docs/README.md").is_ok());
|
|
219
|
+
|
|
220
|
+
let abs_path = sandbox.join("src").join("main.cbl");
|
|
221
|
+
assert!(validate_sandbox_path(sandbox, &abs_path.to_string_lossy()).is_ok());
|
|
222
|
+
|
|
223
|
+
assert!(validate_sandbox_path(sandbox, "../outside.md").is_err());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn write_sandbox_file_creates_docs_markdown() {
|
|
228
|
+
let dir = tempdir().unwrap();
|
|
229
|
+
let sandbox = dir.path();
|
|
230
|
+
|
|
231
|
+
let path = write_sandbox_file(sandbox, "docs/analysis/init.md", "# Init\n").unwrap();
|
|
232
|
+
assert!(path.starts_with(sandbox.join("docs")));
|
|
233
|
+
assert_eq!(std::fs::read_to_string(path).unwrap(), "# Init\n");
|
|
234
|
+
|
|
235
|
+
write_sandbox_file(sandbox, "docs/analysis/init.md", "# Init\n\nMore\n").unwrap();
|
|
236
|
+
assert_eq!(
|
|
237
|
+
std::fs::read_to_string(sandbox.join("docs/analysis/init.md")).unwrap(),
|
|
238
|
+
"# Init\n\nMore\n"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn write_sandbox_file_allows_non_docs_paths_for_migration() {
|
|
244
|
+
let dir = tempdir().unwrap();
|
|
245
|
+
let sandbox = dir.path();
|
|
246
|
+
|
|
247
|
+
let path = write_sandbox_file(sandbox, "src/MAIN.cbl", "IDENTIFICATION DIVISION.").unwrap();
|
|
248
|
+
assert!(path.ends_with("MAIN.cbl"));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn windows_style_docs_paths_are_normalized() {
|
|
253
|
+
let dir = tempdir().unwrap();
|
|
254
|
+
let sandbox = dir.path();
|
|
255
|
+
|
|
256
|
+
write_sandbox_file(sandbox, "docs\\analysis\\windows.md", "# Windows\n").unwrap();
|
|
257
|
+
assert_eq!(
|
|
258
|
+
std::fs::read_to_string(sandbox.join("docs/analysis/windows.md")).unwrap(),
|
|
259
|
+
"# Windows\n"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[test]
|
|
264
|
+
fn write_sandbox_file_rejects_unsafe_or_invalid_paths() {
|
|
265
|
+
let dir = tempdir().unwrap();
|
|
266
|
+
let sandbox = dir.path();
|
|
267
|
+
|
|
268
|
+
assert!(write_sandbox_file(sandbox, "../escape.md", "bad").is_err());
|
|
269
|
+
assert!(write_sandbox_file(sandbox, "..\\escape.md", "bad").is_err());
|
|
270
|
+
assert!(write_sandbox_file(sandbox, "docs/CON.md", "bad").is_err());
|
|
271
|
+
assert!(write_sandbox_file(sandbox, "docs/notes/not-markdown.txt", "bad").is_err());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#[test]
|
|
275
|
+
fn normalize_relative_path_rejects_absolute_and_reserved_names() {
|
|
276
|
+
assert!(normalize_relative_path("C:\\tmp\\escape.md").is_err());
|
|
277
|
+
assert!(normalize_relative_path("\\\\srv\\share\\x.md").is_err());
|
|
278
|
+
assert!(normalize_relative_path("CON.md").is_err());
|
|
279
|
+
}
|
|
280
|
+
}
|
package/src/ui/draw.rs
CHANGED
|
@@ -493,6 +493,7 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
|
|
493
493
|
"/tokens" => " Show token consumption statistics",
|
|
494
494
|
"/init" => " Scan sandbox directory for COBOL",
|
|
495
495
|
"/docs" => " Generate docs for all COBOL files",
|
|
496
|
+
"/skills" => " Check which agent skills are actually loaded",
|
|
496
497
|
"/exit" => " Exit TUI",
|
|
497
498
|
_ => "",
|
|
498
499
|
};
|