Skip to content

Commit f008e6d

Browse files
author
TinyCode
committed
hardening: default sessions, headless-safe permissions, symlink boundary guard, PTY smoke tests
1 parent d486c51 commit f008e6d

26 files changed

Lines changed: 994 additions & 99 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ jobs:
2323

2424
# Some npm versions gate install scripts; esbuild needs its postinstall
2525
# to wire the platform binary used by tsx and vitest.
26+
# Some npm versions gate install scripts; esbuild and node-pty both need
27+
# their postinstall steps (platform binary wiring / native compile).
2628
- name: Install dependencies
27-
run: npm install && npm rebuild esbuild
29+
run: npm install && npm rebuild esbuild && npm rebuild node-pty
2830

2931
- name: Typecheck
3032
run: npm run typecheck

ARCHITECTURE.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,12 @@ uniform surface.
8484
| `find` | glob (`**` crosses dirs), sorted relative paths |
8585
| `ls` | type markers + sizes, dirs first |
8686

87-
All path-taking tools enforce the project boundary (`requirePathInsideProject`) before any I/O.
87+
All path-taking tools enforce the project boundary through `resolveWorkspacePath`
88+
(src/tools/paths.ts) before any I/O: the lexical path is resolved, then **canonicalized with
89+
`fs.realpathSync` on both sides** (existing target — or nearest existing ancestor for new
90+
files — versus canonical project root). Symlink escapes (`link -> /etc/hosts`, writing through
91+
a symlinked directory, broken symlinks) are rejected with a model-friendly
92+
`Path resolves outside project directory: …` error while ordinary relative paths keep working.
8893

8994
## 4. Tool execution flow
9095

@@ -116,10 +121,22 @@ Three layers (src/permissions):
116121
Unknown verbs are treated as `write`.
117122
2. **rules.ts** — per-tool defaults: reads inside the project → ALLOW; writes anywhere and
118123
reads outside → ASK; bash routes through the classifier; unknown tools → ASK.
119-
3. **manager.ts** — the runtime gate. ASK verdicts consult session-scoped "always allow"
120-
patterns (e.g. `bash: npm install …` family), then `mode` (`auto` approves everything),
121-
then the host's prompt callback. No callback ⇒ safe deny. The TUI dialog offers
122-
*Allow once / Always allow this pattern / Deny*.
124+
3. **manager.ts** — the runtime gate. Order of evaluation:
125+
126+
```
127+
hard DENY rule (catastrophic shell: rm -rf /, mkfs, raw disk write, …)
128+
→ refused unconditionally; auto mode and dialogs can never override it
129+
ALLOW verdict → run
130+
ASK verdict → remembered "always allow" pattern?
131+
→ mode === "auto"? approved
132+
→ prompt callback available? dialog decides
133+
→ otherwise safe DENY
134+
```
135+
136+
Semantics differ by surface: the TUI shows the dialog (*Allow once / Always allow this
137+
pattern / Deny*); headless `-p` has no dialog, so its default is deny-on-ASK and automation
138+
requires the explicit `--permission-mode auto` opt-in. SIGINT and the Ctrl+C binding share
139+
the same interrupt logic so ISIG terminals behave identically.
123140

124141
## 6. Context engineering
125142

@@ -138,6 +155,12 @@ Three layers (src/permissions):
138155

139156
## 7. Session
140157

158+
Every interactive launch owns a session from message one: plain `tinycode` maps to
159+
`{mode:"new"}`, `--continue` attaches the newest session whose stored cwd matches (never
160+
another project's; falls back to a new session with a note when none matches), `--session <id>`
161+
attaches exactly that id. `/new` rotates the id and clears the live transcript — Pi's
162+
`Agent.reset()` preserves systemPrompt/model/tools/hooks, so tool calling continues seamlessly.
163+
141164
One JSONL file per session in `<dataHome>/sessions/<id>.jsonl` (id = UUIDv7):
142165

143166
```jsonl
@@ -147,11 +170,11 @@ One JSONL file per session in `<dataHome>/sessions/<id>.jsonl` (id = UUIDv7):
147170
{"type":"message","message":{ …toolResult… }}
148171
```
149172

150-
Writes are synchronous appends; a torn final line (crash mid-append) is skipped on load.
151-
The header is rewritten once when the first real prompt lands so `/sessions` shows titles.
152-
`--continue` attaches the newest session for the cwd; `--session <id>` any other; attach
153-
restores the transcript verbatim into the live `Agent`. Tests redirect storage via
154-
`TINYCODE_HOME`.
173+
Writes are synchronous appends — files are never truncated after creation (the first real
174+
prompt adds the title by rewriting the not-yet-valuable header line only). `attach()` is
175+
strictly read-only: it restores the transcript into the live `Agent` and keeps appending to
176+
the same file, so a crash during resume cannot destroy history. A torn final line (crash
177+
mid-append) is skipped on load. Tests redirect storage via `TINYCODE_HOME`.
155178

156179
## 8. Skills
157180

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,14 @@ cd tinycode && npm install && npm run build
9292
npm run dev # full-screen terminal agent (needs a provider key)
9393
ANTHROPIC_API_KEY=sk-… npm run dev # e.g. Anthropic — keys come from env only
9494
TINYCODE_MODEL=mock npm run dev # offline: scripted mock model, zero setup
95-
tinycode -p "describe this project" # one-shot non-interactive mode
95+
tinycode -p "describe this project" # one-shot mode (read-only by default)
96+
tinycode -p "refactor x" --permission-mode auto # explicit opt-in to unattended writes
9697
```
9798

99+
> **Non-interactive safety:** `-p` runs headless — there is no approval dialog. ASK-level
100+
> operations are therefore **denied** unless you explicitly pass `--permission-mode auto`
101+
> (or set `TINYCODE_PERMISSION_MODE=auto`). Read-only commands run normally.
102+
98103
Supported providers include Anthropic, OpenAI, Groq, DeepSeek, Mistral, OpenRouter,
99104
Google and [more](https://github.com/earendil-works/pi) — everything Pi's catalog covers.
100105

@@ -108,8 +113,9 @@ Google and [more](https://github.com/earendil-works/pi) — everything Pi's cata
108113
- **Permissions** — reads inside the project flow freely; writes, installs and dangerous shell
109114
commands open an approval dialog (*Allow once / Always allow this pattern / Deny*).
110115
A heuristic classifier routes `npm test` vs `rm -rf` vs `curl … | sh`.
111-
- **Sessions** — append-only JSONL under `~/.tinycode/sessions`; resume with `--continue`,
112-
`--session <id>` or `/resume`. Crash-tolerant by design.
116+
- **Sessions** — every interactive launch owns a live session (append-only JSONL under
117+
`~/.tinycode/sessions`). Resume with `--continue` (newest session *of the current
118+
directory only*), `--session <id>` or `/resume`; `/new` starts fresh at any time.
113119
- **Context engineering** — oversized tool results truncate head+tail with full output saved
114120
as artifacts; past a token budget, old turns compact into a `<conversation-summary>`
115121
while recent messages stay verbatim.
@@ -143,6 +149,16 @@ Google and [more](https://github.com/earendil-works/pi) — everything Pi's cata
143149
Environment: provider API keys, `TINYCODE_MODEL=provider/model` (or `mock`),
144150
`TINYCODE_PERMISSION_MODE=ask|auto`, `TINYCODE_HOME` (data-dir redirect used by tests).
145151

152+
## Security notes
153+
154+
TinyCode's permission system is an **approval layer + workspace path guard, not an OS sandbox**:
155+
156+
- File tools enforce the project boundary with symlink-aware canonicalization (`realpath`
157+
both sides), so `link -> /etc/hosts` cannot be used to escape the workspace.
158+
- Shell commands pass a risk classifier plus the same approval flow; they are not confined —
159+
an approved `bash` call can do anything your user can.
160+
- Running genuinely untrusted code/tasks requires an external sandbox (container, VM).
161+
146162
## Documentation
147163

148164
| Doc | Contents |

package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@
2828
"devDependencies": {
2929
"@types/node": "^24.0.0",
3030
"eslint": "^9.30.0",
31+
"node-pty": "^1.1.0",
32+
"tsx": "^4.20.0",
3133
"typescript": "^5.8.0",
3234
"typescript-eslint": "^8.35.0",
33-
"tsx": "^4.20.0",
3435
"vitest": "^3.2.0"
3536
},
3637
"allowScripts": {
37-
"esbuild@0.28.2": true
38+
"esbuild@0.28.2": true,
39+
"node-pty@1.1.0": true
3840
},
3941
"keywords": [
4042
"ai",

src/cli/index.ts

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import fs from "node:fs";
22
import { buildHarnessFromCli, printHelp, printVersion, reportError } from "./commands.js";
33
import { CliArgsError, parseArgs } from "./args.js";
44
import { TuiApp } from "../tui/app.js";
5-
import { sessionsDir } from "../config/loader.js";
6-
import { SessionManager } from "../session/manager.js";
5+
import { resolveInteractiveSession, type SessionOption } from "./sessions.js";
76
import type { AgentMessage } from "@earendil-works/pi-agent-core";
87

98
async function main(): Promise<number> {
@@ -42,13 +41,9 @@ async function main(): Promise<number> {
4241
return runPrintMode(cwd, args.prompt, args);
4342
}
4443

45-
// Interactive TUI.
44+
// Interactive TUI — always owns a session (new or attached).
4645
try {
47-
const session = args.continueLast
48-
? await resolveLatestSession(cwd)
49-
: args.sessionId
50-
? ({ mode: "attach", id: args.sessionId } as const)
51-
: undefined;
46+
const session: SessionOption = resolveInteractiveSession(args, cwd);
5247
const harness = await buildHarnessFromCli({
5348
cwd,
5449
modelFlag: args.model,
@@ -65,7 +60,15 @@ async function main(): Promise<number> {
6560
subAgents: harness.subAgents,
6661
projectRoot: cwd,
6762
});
68-
await app.run();
63+
// Real terminals with ISIG deliver Ctrl+C as SIGINT; route it through the
64+
// same interrupt logic as the in-app keybinding.
65+
const onSigint = () => app.handleInterrupt();
66+
process.on("SIGINT", onSigint);
67+
try {
68+
await app.run();
69+
} finally {
70+
process.off("SIGINT", onSigint);
71+
}
6972
await harness.shutdown();
7073
return 0;
7174
} catch (error) {
@@ -80,15 +83,17 @@ async function runPrintMode(
8083
args: ReturnType<typeof parseArgs>,
8184
): Promise<number> {
8285
try {
83-
const session = args.continueLast
84-
? await resolveLatestSession(cwd)
86+
// Headless runs persist only when explicitly resuming; ASK verdicts deny
87+
// because there is no dialog — auto-approval requires an explicit opt-in.
88+
const session: SessionOption | undefined = args.continueLast
89+
? resolveInteractiveSession(args, cwd)
8590
: args.sessionId
86-
? ({ mode: "attach", id: args.sessionId } as const)
91+
? { mode: "attach", id: args.sessionId }
8792
: undefined;
8893
const harness = await buildHarnessFromCli({
8994
cwd,
9095
modelFlag: args.model,
91-
permissionMode: args.permissionMode ?? "auto",
96+
permissionMode: args.permissionMode ?? "ask",
9297
mock: args.mock,
9398
session,
9499
});
@@ -124,19 +129,6 @@ function extractFinalText(messages: readonly AgentMessage[]): string {
124129
return "";
125130
}
126131

127-
/** Resolve the most recent session for --continue. */
128-
async function resolveLatestSession(
129-
cwd: string,
130-
): Promise<{ mode: "attach"; id: string } | undefined> {
131-
const manager = new SessionManager(sessionsDir());
132-
const latest = manager.list().find((session) => session.cwd === cwd);
133-
if (!latest) {
134-
process.stderr.write("tinycode: no previous session to continue.\n");
135-
process.exit(2);
136-
}
137-
return { mode: "attach", id: latest.id };
138-
}
139-
140132
/** --list-models: show models whose providers have credentials configured. */
141133
async function listModels(cwd: string): Promise<number> {
142134
try {

src/cli/sessions.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { CliArgs } from "./args.js";
2+
import { SessionManager } from "../session/manager.js";
3+
import type { SessionSummary } from "../session/types.js";
4+
import { sessionsDir } from "../config/loader.js";
5+
6+
/**
7+
* Session option resolution for CLI entry points.
8+
*
9+
* Lifecycle:
10+
* tinycode → new session (always)
11+
* tinycode --continue → newest session whose stored cwd matches
12+
* tinycode --session <id> → that exact session
13+
*
14+
* `--continue` never resumes another project's session; when nothing matches
15+
* it falls back to a fresh one with a note instead of failing the launch.
16+
*/
17+
export type SessionOption = { mode: "new" } | { mode: "attach"; id: string };
18+
19+
export function pickLatestSessionForCwd(
20+
manager: SessionManager,
21+
cwd: string,
22+
): SessionSummary | undefined {
23+
return manager.list().find((session) => session.cwd === cwd);
24+
}
25+
26+
export function resolveInteractiveSession(
27+
args: Pick<CliArgs, "continueLast" | "sessionId">,
28+
cwd: string,
29+
stderr: (line: string) => void = (line) => process.stderr.write(`${line}\n`),
30+
): SessionOption {
31+
if (args.continueLast) {
32+
const latest = pickLatestSessionForCwd(new SessionManager(sessionsDir()), cwd);
33+
if (!latest) {
34+
stderr("tinycode: no previous session to continue — starting a fresh one.");
35+
return { mode: "new" };
36+
}
37+
return { mode: "attach", id: latest.id };
38+
}
39+
if (args.sessionId) {
40+
return { mode: "attach", id: args.sessionId };
41+
}
42+
// Plain interactive launch owns a live session from the first message.
43+
return { mode: "new" };
44+
}

src/permissions/rules.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ export function resolveToolPath(
4141

4242
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
4343

44+
/**
45+
* Hard DENY for catastrophic shell commands. These are refused outright —
46+
* auto mode, remembered patterns, and user approval never override them.
47+
* Deliberately tiny: only commands whose damage is unrecoverable.
48+
*/
49+
const HARD_DENY_BASH: Array<{ label: string; test: RegExp }> = [
50+
{ label: "recursive force-delete of filesystem root", test: /\brm\s+[^\n]*\s\/\*?\s*(?:&&|$|;)/ },
51+
{ label: "delete home directory", test: /\brm\s+[^\n]*\s(~|\$HOME)(?:\s|$)/ },
52+
{ label: "format filesystem", test: /\bmkfs(\.\w+)?\b/ },
53+
{ label: "raw disk write", test: /\bdd\b[^\n]*\bof=\/dev\/(disk|sd|nvme|mmcblk)/ },
54+
{ label: "world-writable root", test: /\bchmod\s+-R\s+777\s+\// },
55+
];
56+
57+
export function findHardDeny(command: string): string | undefined {
58+
for (const rule of HARD_DENY_BASH) {
59+
if (rule.test.test(command)) return rule.label;
60+
}
61+
return undefined;
62+
}
63+
4464
export function evaluateRules({
4565
toolName,
4666
input,
@@ -63,6 +83,10 @@ export function evaluateRules({
6383

6484
if (toolName === "bash") {
6585
const command = String(input.command ?? "");
86+
const denied = findHardDeny(command);
87+
if (denied) {
88+
return { action: "deny", reason: `catastrophic command refused: ${denied}` };
89+
}
6690
const { insideProject } = resolveToolPath(
6791
projectRoot,
6892
typeof input.cwd === "string" ? input.cwd : undefined,

src/session/manager.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,22 @@ export class SessionManager {
3030
return this.currentId;
3131
}
3232

33-
/** Attach to an existing session (used by --continue / --session). */
34-
attach(id: string, cwd: string, model: string): { messages: AgentMessage[] } {
33+
/**
34+
* Attach to an existing session (used by --continue / --session / /resume).
35+
*
36+
* The file is left untouched — attach is read-only. Rewriting the header
37+
* plus full history here would risk truncating the session if the process
38+
* died between truncate and re-append; the stored header stays canonical.
39+
*/
40+
attach(id: string, _cwd: string, _model: string): { messages: AgentMessage[] } {
3541
const loaded = this.storage.load(id);
3642
if (!loaded) throw new Error(`Session not found: ${id}`);
3743
this.currentId = id;
3844
const firstUser = loaded.messages.find((message) => message.role === "user");
3945
if (firstUser && firstUser.role === "user" && typeof firstUser.content === "string") {
4046
this.lastUserText = firstUser.content.replace(/\s+/g, " ").slice(0, 80);
4147
}
42-
// Rewrite the file: refreshed header (model may have changed) + full history.
43-
this.storage.create({
44-
id,
45-
cwd,
46-
createdAt: loaded.header.createdAt,
47-
model,
48-
title: loaded.header.title ?? this.lastUserText,
49-
});
50-
for (const message of loaded.messages) {
51-
this.storage.appendMessage(id, message);
52-
}
53-
this.meta = { cwd, model, createdAt: loaded.header.createdAt, titleSet: true };
48+
this.meta = { cwd: loaded.header.cwd, model: loaded.header.model, createdAt: loaded.header.createdAt, titleSet: true };
5449
return { messages: loaded.messages };
5550
}
5651

0 commit comments

Comments
 (0)