totopo 3.6.0 → 3.7.0-rc-1

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/README.md CHANGED
@@ -10,6 +10,12 @@ Local sandbox for AI agents.
10
10
  ![npm downloads](https://img.shields.io/npm/dm/totopo)
11
11
  ![license](https://img.shields.io/npm/l/totopo)
12
12
 
13
+ ## Who this is for
14
+
15
+ Developers who use `claude`, `codex`, or `opencode` **interactively** — one human pair-programming with one agent.
16
+
17
+ totopo isn't an orchestration tool (no SDK, no parallel agents, no per-run worktrees), and its security is basic — just the minimum precautions I think anyone running AI agents should take. If you need more on either front, look elsewhere.
18
+
13
19
  ## Motivation
14
20
 
15
21
  Two fundamental risks when running AI agents locally:
@@ -21,8 +27,6 @@ Totopo mitigates both risks by letting you run agents in a dev container — whe
21
27
 
22
28
  In practice, this means any mistake can be reverted from your git remote, and even a compromised agent can't access sensitive files on your machine — SSH keys, credentials, browser data — things a locally-running agent could otherwise do without you ever noticing.
23
29
 
24
- > totopo's security approach is basic — it is about the minimal precautions I believe anyone running AI agents should have. If you need more robust protections, look somewhere else.
25
-
26
30
  ## Requirements
27
31
 
28
32
  - [Docker](https://www.docker.com/products/docker-desktop/) — builds and runs the dev container
@@ -50,7 +54,7 @@ A few things happen automatically:
50
54
 
51
55
  - **Agents stay up to date** — totopo keeps all AI CLIs on their latest versions, checking for updates automatically.
52
56
  - **Sessions are persistent** — agent memory and settings survive container restarts and rebuilds.
53
- - **Your machine stays safe** — the container can't push to remote, can't read outside the workspace, and sensitive paths like `.env` can be hidden from agents entirely (see [Shadow Paths](#shadow-paths)).
57
+ - **The blast radius is bounded** — the container can't push to remote or read outside the workspace, and you can hide files like `.env` from the agent (see [Shadow Paths](#shadow-paths)). For what this does and doesn't protect against, see [Threat Model](#threat-model).
54
58
 
55
59
  For a deeper look at how totopo works and how to configure it, see the sections below.
56
60
 
@@ -160,6 +164,8 @@ shadow_paths:
160
164
 
161
165
  Patterns follow gitignore syntax — patterns without a `/` match at any depth. Manage via **Manage Workspace > Shadow paths** or edit `totopo.yaml` directly. Changes take effect on the next session.
162
166
 
167
+ Git-tracked paths are skipped to avoid worktree diversions. Shadowing them has no privacy benefit anyway since agents can `git show` tracked content. To hide a file, untrack it and add it to `.gitignore` first.
168
+
163
169
  Common use cases:
164
170
  - **Separate `node_modules`** — the container installs its own dependencies, avoiding platform conflicts between host and container.
165
171
  - **Hide sensitive files** — keep credentials and secrets invisible to agents.
@@ -250,6 +256,20 @@ To clear memory: `npx totopo` → **Manage totopo > Clear agent memory**.
250
256
  }
251
257
  ```
252
258
 
259
+ ## Threat Model
260
+
261
+ Totopo makes everyday agent mistakes safer. It is not built to stop a determined attacker.
262
+
263
+ **What it helps with:**
264
+ - Confused-agent mistakes — only the workspace is at risk; the rest of your machine is invisible.
265
+ - Casual prompt injection — the agent can't read your SSH keys or push to your remote, because neither is in the container.
266
+
267
+ **What it does not help with:**
268
+ - A determined adversarial agent. The network is open, and `/usr/bin/git` is reachable by absolute path even in strict mode. Anything in the workspace can still leave over HTTP.
269
+ - Secrets you didn't shadow. A `.env` tracked in git is visible unless you list it in `shadow_paths`.
270
+ - Container escapes. Totopo uses a non-root user and `no-new-privileges`, but no capability drops or seccomp profiles. For stronger isolation, use a microVM sandbox.
271
+ - Edits to your working tree. The workspace is bind-mounted, so agent changes land on your real files. Commit often.
272
+
253
273
  ## Disclaimer
254
274
 
255
275
  MIT licensed and fully open source. Issues welcome — no promises on response time. Use at your own risk.
@@ -263,10 +263,13 @@ export async function run(packageDir, ctx, options) {
263
263
  const profileHook = profileConfig?.dockerfile_hook;
264
264
  // --- Shadow path expansion -----------------------------------------------------------------------------------------------------------
265
265
  const shadowPatterns = yaml.shadow_paths ?? [];
266
- const expandedShadows = expandShadowPatterns(shadowPatterns, workspaceDir);
266
+ const { paths: expandedShadows, skippedTracked } = expandShadowPatterns(shadowPatterns, workspaceDir);
267
267
  if (expandedShadows.length > 0) {
268
268
  log.warn(`Shadow paths active: ${expandedShadows.join(", ")} (Settings > Shadow paths)`);
269
269
  }
270
+ if (skippedTracked.length > 0) {
271
+ log.warn(`Skipped ${skippedTracked.length} shadow path(s) tracked by git`);
272
+ }
270
273
  // --- Env file ------------------------------------------------------------------------------------------------------------------------
271
274
  let envFilePath;
272
275
  if (yaml.env_file) {
@@ -28,7 +28,8 @@ async function shadowPathsMenu(ctx) {
28
28
  }
29
29
  log.message("Shadow patterns block the agent from seeing matching host paths —\n" +
30
30
  "the container gets an empty, isolated copy instead.\n" +
31
- "Supports gitignore-style patterns (e.g. node_modules, .env*).");
31
+ "Supports gitignore-style patterns (e.g. node_modules, .env*).\n" +
32
+ "Git-tracked paths are skipped to avoid worktree diversions.");
32
33
  const options = [{ value: "add", label: "Add pattern or path" }];
33
34
  if (patterns.length > 0) {
34
35
  options.push({ value: "remove", label: "Remove patterns" });
@@ -2,22 +2,26 @@
2
2
  // src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
3
3
  // Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
4
4
  // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
5
6
  import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join, relative } from "node:path";
7
8
  import fg from "fast-glob";
8
9
  import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
9
10
  import { safeRmSync } from "./safe-rm.js";
10
- // --- Pattern expansion -------------------------------------------------------------------------------------------------------------------
11
11
  /**
12
12
  * Expand gitignore-style patterns into concrete relative paths.
13
13
  *
14
14
  * Patterns without a directory separator are treated as recursive (prepended with **/)
15
15
  * following gitignore convention. Patterns with a / are matched relative to the workspace root.
16
16
  * Matched directories are not recursed into (e.g. node_modules matches once, not its children).
17
+ *
18
+ * Paths that git tracks (or whose descendants git tracks) are dropped from the result and
19
+ * reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
20
+ * and breaks `git stash`/`pop` workflows.
17
21
  */
18
22
  export function expandShadowPatterns(patterns, workspaceRoot) {
19
23
  if (patterns.length === 0)
20
- return [];
24
+ return { paths: [], skippedTracked: [] };
21
25
  // Convert gitignore-style patterns to fast-glob patterns
22
26
  const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
23
27
  // Build ignore list: skip .git and contents of any matched directory
@@ -28,12 +32,51 @@ export function expandShadowPatterns(patterns, workspaceRoot) {
28
32
  dot: true,
29
33
  ignore: ignorePatterns,
30
34
  });
31
- return removeNestedPaths(results.sort());
35
+ const expanded = removeNestedPaths(results.sort());
36
+ const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
37
+ return { paths: kept, skippedTracked: dropped };
32
38
  }
33
39
  // --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
34
- /** Count how many paths a pattern would match in the workspace. */
40
+ /** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
35
41
  export function countPatternHits(pattern, workspaceRoot) {
36
- return expandShadowPatterns([pattern], workspaceRoot).length;
42
+ return expandShadowPatterns([pattern], workspaceRoot).paths.length;
43
+ }
44
+ // --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
45
+ /**
46
+ * Partition expanded shadow paths into those safe to shadow and those tracked by git.
47
+ * A path is dropped if it equals a tracked file, or (for directories) any tracked file
48
+ * lives anywhere beneath it. Returns the input unchanged when no .git is present.
49
+ */
50
+ function filterGitTrackedPaths(paths, workspaceRoot) {
51
+ if (paths.length === 0)
52
+ return { kept: [], dropped: [] };
53
+ // .git may be a file (worktrees) or a directory; existsSync handles both
54
+ if (!existsSync(join(workspaceRoot, ".git")))
55
+ return { kept: paths, dropped: [] };
56
+ // Pass shadow paths as pathspecs so git enumerates only tracked files within them.
57
+ // Output then scales with shadow scope (typically tiny), not the size of the repo index.
58
+ const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ if (result.status !== 0)
63
+ return { kept: paths, dropped: [] };
64
+ const matched = result.stdout.split("\0").filter((s) => s.length > 0);
65
+ if (matched.length === 0)
66
+ return { kept: paths, dropped: [] };
67
+ const matchedSet = new Set(matched);
68
+ const kept = [];
69
+ const dropped = [];
70
+ for (const p of paths) {
71
+ const prefix = `${p}/`;
72
+ if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
73
+ dropped.push(p);
74
+ }
75
+ else {
76
+ kept.push(p);
77
+ }
78
+ }
79
+ return { kept, dropped };
37
80
  }
38
81
  // --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
39
82
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.6.0",
3
+ "version": "3.7.0-rc-1",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,6 @@
1
1
  ## Git availability
2
2
 
3
- The user set git mode to **local** in totopo.
4
-
5
- Please respect this choice — do not attempt remote git operations (push, pull, fetch, clone).
3
+ The user set git mode to **local** in totopo - do not attempt remote git operations (push, pull, fetch, clone).
6
4
 
7
5
  totopo enforces this by:
8
6
  - Blocking remote access (push, pull, fetch, clone).
@@ -1,8 +1,6 @@
1
1
  ## Git availability
2
2
 
3
- The user set git mode to **strict** in totopo.
4
-
5
- Please respect this choice — do not attempt git operations that modify state or interact with remotes.
3
+ The user set git mode to **strict** in totopo — do not attempt git operations that modify state or interact with remotes.
6
4
 
7
5
  totopo enforces this by:
8
6
  - Blocking git commands that would modify the repository (attempts return a clear error).
@@ -1,3 +1,3 @@
1
1
  ## Git availability
2
2
 
3
- The user set git mode to **unrestricted** in totopo. totopo does not enforce any git-specific restrictions in this mode; git operations are subject only to git's own behavior and to any credentials configured in the container.
3
+ The user set git mode to **unrestricted** in totopo. totopo does not enforce any git-specific restrictions in this mode.
@@ -1,5 +1,5 @@
1
1
  ## Git availability
2
2
 
3
- Git is **not available** — no `.git` directory was found in the workspace root.
3
+ Git is not available — no `.git` directory was found in the workspace root.
4
4
 
5
5
  If git operations are needed, ask the user to run them on the host.
@@ -1,4 +1,4 @@
1
1
  ## Your responsibilities at session start
2
2
 
3
3
  At the start of every session:
4
- - Briefly tell the user they are in a totopo sandbox and mention key limitations (git remote block, no host filesystem access outside the workspace).
4
+ - Briefly tell the user they are in a totopo sandbox and mention key limitations (totopo git mode, no host filesystem access outside the workspace).
@@ -1,7 +1,6 @@
1
1
  ## Shadow paths
2
2
 
3
- The following patterns are overlaid with container-local storage and do not reflect
4
- the host filesystem:
3
+ The following patterns are overlaid with container-local storage and do not reflect the host filesystem:
5
4
 
6
5
  {{pattern_list}}
7
6
 
@@ -9,3 +8,5 @@ Matching paths are initialized empty on first use. The container may accumulate
9
8
  content in them over time (for example, a shadowed `node_modules` gets
10
9
  populated when you run `npm install` inside the container). Do not assume they
11
10
  are empty, and do not attempt to sync or restore their host contents.
11
+
12
+ Git-tracked paths are skipped to avoid worktree diversions.
@@ -1,3 +1,3 @@
1
1
  ## Workspace
2
2
 
3
- You have access to the full workspace directory at `/workspace`. Some operations (git push, system-level changes) require running on the host.
3
+ You have access to the full workspace directory at `/workspace`. Some operations may require running on the host.
@@ -51,19 +51,17 @@ const TIMESTAMP_FILE = "/home/devuser/.ai-cli-updated";
51
51
  const THROTTLE_MS = 24 * 60 * 60 * 1000;
52
52
 
53
53
  let lastUpdate = 0;
54
+ let timestampFileExists = false;
54
55
  try {
55
56
  const raw = readFileSync(TIMESTAMP_FILE, "utf8").trim();
56
57
  lastUpdate = new Date(raw).getTime();
58
+ timestampFileExists = true;
57
59
  } catch {
58
60
  // File missing or unreadable -- treat as never updated
59
61
  }
60
62
 
61
- if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
62
- ok("AI CLIs", "up to date");
63
- } else if (!isRoot) {
64
- skip("AI CLIs", "update skipped (requires root)");
65
- } else {
66
- console.log(`${blue("●")} ${dim("Updating AI CLIs to latest...")}`);
63
+ const doUpdate = (label) => {
64
+ console.log(`${blue("")} ${dim(label)}`);
67
65
  try {
68
66
  execSync("npm install -g opencode-ai@latest @anthropic-ai/claude-code@latest @openai/codex@latest", {
69
67
  stdio: "inherit",
@@ -73,6 +71,64 @@ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
73
71
  } catch {
74
72
  fail("AI CLIs", "update failed -- continuing with existing versions");
75
73
  }
74
+ };
75
+
76
+ // SPACE within `seconds` -> skip. Any other input is ignored. Ctrl+C exits 130. Non-TTY -> no skip.
77
+ const promptSkipUpdate = (seconds) =>
78
+ new Promise((resolve) => {
79
+ if (!process.stdin.isTTY) {
80
+ resolve(false);
81
+ return;
82
+ }
83
+ let remaining = seconds;
84
+ let tick;
85
+ let timer;
86
+ const line = (s) => `\r\x1b[K${blue("●")} ${dim(`Updating AI CLIs in ${s}s... press SPACE to skip`)}`;
87
+ const cleanup = () => {
88
+ clearInterval(tick);
89
+ clearTimeout(timer);
90
+ process.stdin.removeListener("data", onData);
91
+ process.stdin.setRawMode(false);
92
+ process.stdin.pause();
93
+ process.stdout.write("\r\x1b[K");
94
+ };
95
+ const onData = (chunk) => {
96
+ for (const byte of chunk) {
97
+ if (byte === 0x03) {
98
+ cleanup();
99
+ process.exit(130);
100
+ }
101
+ if (byte === 0x20) {
102
+ cleanup();
103
+ resolve(true);
104
+ return;
105
+ }
106
+ }
107
+ };
108
+ process.stdin.setRawMode(true);
109
+ process.stdin.resume();
110
+ process.stdin.on("data", onData);
111
+ process.stdout.write(line(remaining));
112
+ tick = setInterval(() => {
113
+ remaining -= 1;
114
+ if (remaining > 0) process.stdout.write(line(remaining));
115
+ }, 1000);
116
+ timer = setTimeout(() => {
117
+ cleanup();
118
+ resolve(false);
119
+ }, seconds * 1000);
120
+ });
121
+
122
+ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
123
+ ok("AI CLIs", "up to date");
124
+ } else if (!isRoot) {
125
+ skip("AI CLIs", "update skipped (requires root)");
126
+ } else if (!timestampFileExists) {
127
+ doUpdate("Installing AI CLIs...");
128
+ } else if (await promptSkipUpdate(5)) {
129
+ skip("AI CLIs", "update skipped by user");
130
+ } else {
131
+ doUpdate("Updating AI CLIs to latest...");
76
132
  }
77
133
 
78
134
  // -- Security -----------------------------------------------------------------