coder
v0.2.0Path-jailed code worker — read/search/update/create/delete files plus paginated list-folder and tree, with non-accessible glob protection.
- macOS: arm64 · x64
- Linux: arm64 · armv7 · x64
install
configuration
- base_path: ./
list_default_page_size: 100
list_max_page_size: 1000
max_read_bytes: 10485760
max_write_bytes: 10485760
non_accessible_globs:
- **/.env
- **/.env.*
- **/*.pem
- **/*.key
- **/secrets/**
search_default_max_line_bytes: 4096
search_default_max_matches: 1000
tree_default_depth: 4
tree_per_folder_limit: 50readme
open as markdowncoder
A path-jailed code worker for iii agents. coder::* lets agents read,
search, edit, create, and delete files inside a single configured
base_path — without ever escaping it via .., absolute paths, or
symlinks. A glob-based non_accessible list keeps sensitive files
(.env, *.pem, anything under secrets/) visible to directory
listings but unreadable and unwritable
Install
iii worker add coderiii worker add fetches the binary, writes a config block into
~/.iii/config.yaml, and the engine starts the worker on the next
iii start.
Quickstart
use iii_sdk::{register_worker, InitOptions, TriggerRequest};
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let iii = register_worker("ws://localhost:49134", InitOptions::default());
// Create a file.
iii.trigger(TriggerRequest {
function_id: "coder::create-file".into(),
payload: json!({
"files": [{
"path": "notes.md",
"content": "# notes\n- one\n- two\n",
"overwrite": false
}]
}),
action: None,
timeout_ms: Some(5_000),
}).await?;
// Apply two ops bottom-up in a single batch.
iii.trigger(TriggerRequest {
function_id: "coder::update-file".into(),
payload: json!({
"files": [{
"path": "notes.md",
"ops": [
{ "op": "insert", "at_line": 2, "content": "draft" },
{ "op": "update_lines", "from_line": 3, "to_line": 3, "content": "- ONE" }
]
}]
}),
action: None,
timeout_ms: Some(5_000),
}).await?;
// Read it back.
let read = iii.trigger(TriggerRequest {
function_id: "coder::read-file".into(),
payload: json!({ "path": "notes.md" }),
action: None,
timeout_ms: Some(5_000),
}).await?;
println!("{read:#?}");
Ok(())
}Functions
| Function id | What it does |
|---|---|
coder::read-file |
Read a single file (capped at max_read_bytes). |
coder::search |
Search file contents (literal/regex) and/or paths under base_path. |
coder::update-file |
Apply batched insert / remove / update_lines / regex replace ops across one or more files. Line ops bottom-up; atomic per file. |
coder::create-file |
Create one or more files with overwrite and parents flags. |
coder::delete-file |
Remove one or more paths; recursive: true required for non-empty dirs. |
coder::list-folder |
Paginated single-folder listing; non-accessible entries flagged. |
coder::tree |
Recursive snapshot bounded by max_depth and per_folder_limit. |
coder::update-file semantics
Line ops (insert, remove, update_lines) use 1-based inclusive
line numbers and are applied bottom-up (highest affected line
first), so each op still references the original line numbers from the
caller's perspective. Overlapping line ops are rejected (C210).
Regex replace ops run after line ops on the full file body. The
whole batch is committed via a sibling temp file + rename, so a failure
mid-write leaves the original file intact.
{
"files": [{
"path": "schema.sql",
"ops": [
{ "op": "insert", "at_line": 1, "content": "-- header\n-- v2" },
{ "op": "remove", "from_line": 5, "to_line": 12 },
{ "op": "update_lines", "from_line": 30, "to_line": 30, "content": "PRIMARY KEY (id)" },
{ "op": "replace", "pattern": "OLD_", "replacement": "NEW_" }
]
}]
}Error codes
All errors return as JSON strings of the form {"code":"C2xx","message":"..."}.
| Code | Meaning |
|---|---|
C210 |
Bad input (malformed payload, illegal line numbers, overlapping ops, absolute path, …) |
C211 |
Path not found OR matches a non_accessible_globs entry |
C213 |
File exceeds max_read_bytes or max_write_bytes |
C215 |
Path escapes base_path lexically or through a symlink |
C216 |
Underlying I/O error |
C217 |
coder::create-file saw an existing file with overwrite=false |
Configuration
base_path: ./ # root every coder::* call is scoped under
non_accessible_globs: # listable but unreadable/unwritable
- "**/.env"
- "**/.env.*"
- "**/*.pem"
- "**/*.key"
- "**/secrets/**"
max_read_bytes: 10485760 # per-file read cap (10 MiB)
max_write_bytes: 10485760 # per-file create/update cap (10 MiB)
tree_default_depth: 4 # coder::tree depth when unset
tree_per_folder_limit: 50 # children before tree truncates a folder
list_default_page_size: 100 # coder::list-folder default page size
list_max_page_size: 1000 # hard cap on coder::list-folder page_size
search_default_max_matches: 1000 # coder::search match cap
search_default_max_line_bytes: 4096 # per-line cap when scanning contentnon_accessible_globs uses the same syntax as the globset crate (so
**/, *, ?, character classes, …). Matching is done against the
relative path from base_path, so **/.env blocks .env,
a/.env, and a/b/.env.
Security boundary
base_pathis canonicalised at startup; the worker refuses to start if it can't be reached.- Every wire path must be relative to
base_path; absolute paths returnC210rather than being silently re-jailed. ..and symlinks are resolved against the longest existing ancestor and rejected if they leavebase_path(C215). Dangling symlinks in the tail are also rejected because the kernel would otherwise follow them on the next syscall.- Non-accessible globs apply to reads as well as writes — the same
glob hides the file from
coder::read-file,coder::update-file,coder::create-file,coder::delete-file, and fromcoder::search's content/path matches. - Recursive
coder::delete-filerefuses to descend through a subtree that contains a non-accessible entry rather than removing it.