totopo 3.3.3 → 3.4.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
@@ -77,7 +77,7 @@ On every run, totopo shows the workspace menu:
77
77
 
78
78
  - **Open session** — start or resume the dev container and connect
79
79
  - **Stop container** — stop the running container
80
- - **Manage Workspace** — shadow paths, rebuild, reset config
80
+ - **Manage Workspace** — git mode, shadow paths, rebuild, reset config
81
81
  - **Manage totopo** — multi-workspace management (stop containers, clear memory, uninstall)
82
82
 
83
83
  ### Working directory
@@ -96,11 +96,21 @@ Every session runs inside a Docker container. Your code is bind-mounted from the
96
96
  | No host credentials | Host git credentials are never copied into the container |
97
97
  | No privilege escalation | `no-new-privileges:true` prevents any process from gaining elevated permissions |
98
98
  | Filesystem isolation | Only the workspace directory is mounted; the rest of the host is not visible |
99
- | Git remote block | `protocol.allow = never` in `/etc/gitconfig` push, pull, fetch, and clone are refused |
99
+ | Git guardrails | Per-workspace **git mode** controls what git can do inside the container see [Git Modes](#git-modes) |
100
100
  | Shadow mounts | Selected paths overlaid with isolated container-local copies — see [Shadow Paths](#shadow-paths) |
101
101
  | Environment vars | Injected from a host file at session start (`env_file`) |
102
102
 
103
- Remote git operations are blocked inside the container. Run them from your host terminal.
103
+ ### Git Modes
104
+
105
+ Each workspace has a git mode (set via **Manage Workspace > Git mode**) that controls what git operations are permitted inside the container:
106
+
107
+ | Mode | Local mutations | Remote (push/pull/fetch/clone) |
108
+ |---|---|---|
109
+ | **strict** *(default for new workspaces)* | Blocked — a read-only `git` wrapper allows inspection (`status`, `log`, `diff`, `blame`, `show`, etc.) and rejects mutations (`commit`, `add`, `reset`, `checkout`, etc.) | Blocked at the gitconfig protocol layer |
110
+ | **local** *(default for workspaces upgraded from earlier versions)* | Allowed | Blocked at the gitconfig protocol layer |
111
+ | **unrestricted** | Allowed | Allowed — totopo enforces no git-specific restrictions |
112
+
113
+ The active mode is recorded per workspace in `.lock`, exposed inside the container as `TOTOPO_GIT_MODE`, and reflected in the agent context so each session knows what is permitted. Switching modes recreates the container on the next session.
104
114
 
105
115
  ### Profiles
106
116
 
@@ -203,7 +213,7 @@ To clear memory: `npx totopo` → **Manage totopo > Clear agent memory**.
203
213
  ~/.totopo/
204
214
  └── workspaces/
205
215
  └── <workspace_id>/
206
- ├── .lock # workspace root path + active profile
216
+ ├── .lock # workspace root path, active profile, and git mode
207
217
  ├── agents/ # agent session data (persists across rebuilds)
208
218
  │ ├── claude/
209
219
  │ ├── opencode/
@@ -8,12 +8,12 @@ import { existsSync } from "node:fs";
8
8
  import { join, relative } from "node:path";
9
9
  import { cancel, confirm, isCancel, log, outro, select } from "@clack/prompts";
10
10
  import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
11
- import { CONTAINER_STARTUP, CONTAINER_WORKSPACE, LABEL_MANAGED, LABEL_PROFILE, LABEL_RUNTIME_ENV, LABEL_SHADOWS, PROFILE, RUNTIME_ENV, } from "../lib/constants.js";
11
+ import { CONTAINER_STARTUP, CONTAINER_WORKSPACE, GIT_MODE, LABEL_GIT_MODE, LABEL_MANAGED, LABEL_PROFILE, LABEL_RUNTIME_ENV, LABEL_SHADOWS, PROFILE, RUNTIME_ENV, } from "../lib/constants.js";
12
12
  import { buildDockerfile, buildImageWithTempfile } from "../lib/dockerfile-builder.js";
13
13
  import { isImageStale } from "../lib/migrate-to-latest.js";
14
14
  import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
15
15
  import { readTotopoYaml } from "../lib/totopo-yaml.js";
16
- import { readActiveProfile, writeActiveProfile } from "../lib/workspace-identity.js";
16
+ import { readActiveProfile, readGitMode, writeActiveProfile } from "../lib/workspace-identity.js";
17
17
  // --- Prompt: working directory selection -------------------------------------------------------------------------------------------------
18
18
  async function promptWorkdir(workspaceDir, cwd) {
19
19
  if (cwd === workspaceDir)
@@ -64,13 +64,19 @@ async function selectProfile(ctx, profiles) {
64
64
  }
65
65
  // Returns null when the container does not exist (docker inspect exits non-zero).
66
66
  function inspectContainer(containerName) {
67
- const fmt = `{{.State.Status}}|{{index .Config.Labels "${LABEL_SHADOWS}"}}|{{index .Config.Labels "${LABEL_PROFILE}"}}|{{index .Config.Labels "${LABEL_RUNTIME_ENV}"}}`;
67
+ const fmt = `{{.State.Status}}|{{index .Config.Labels "${LABEL_SHADOWS}"}}|{{index .Config.Labels "${LABEL_PROFILE}"}}|{{index .Config.Labels "${LABEL_RUNTIME_ENV}"}}|{{index .Config.Labels "${LABEL_GIT_MODE}"}}`;
68
68
  const result = spawnSync("docker", ["inspect", "--format", fmt, containerName], { encoding: "utf8", stdio: "pipe" });
69
69
  if (result.status !== 0)
70
70
  return null;
71
71
  const clean = (s) => (s === "<no value>" ? "" : s);
72
- const [status = "", shadows = "", profile = "", runtimeEnv = ""] = result.stdout.trim().split("|");
73
- return { status, shadowLabel: clean(shadows), profileLabel: clean(profile), runtimeEnvLabel: clean(runtimeEnv) };
72
+ const [status = "", shadows = "", profile = "", runtimeEnv = "", gitMode = ""] = result.stdout.trim().split("|");
73
+ return {
74
+ status,
75
+ shadowLabel: clean(shadows),
76
+ profileLabel: clean(profile),
77
+ runtimeEnvLabel: clean(runtimeEnv),
78
+ gitModeLabel: clean(gitMode),
79
+ };
74
80
  }
75
81
  // --- Shadow label ------------------------------------------------------------------------------------------------------------------------
76
82
  function shadowLabel(paths) {
@@ -102,13 +108,13 @@ function runStartup(containerName, quiet) {
102
108
  return result.status === 0;
103
109
  }
104
110
  export function startContainer(opts) {
105
- const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envFilePath, hasGit, shadowPatterns, workspaceName, noCache, quiet = false, } = opts;
111
+ const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envFilePath, hasGit, gitMode, shadowPatterns, workspaceName, noCache, quiet = false, } = opts;
106
112
  const stdio = quiet ? "pipe" : "inherit";
107
113
  // --- Sync shadows and build mount args ------------------------------------------------------------------------------------------------
108
114
  ensureShadowsInSync(cacheDir, expandedShadows, workspaceRoot);
109
115
  const shadowMountArgs = buildShadowMountArgs(cacheDir, expandedShadows);
110
116
  // --- Agent context -------------------------------------------------------------------------------------------------------------------
111
- const agentDocs = buildAgentContextDocs(hasGit, shadowPatterns);
117
+ const agentDocs = buildAgentContextDocs(hasGit, shadowPatterns, gitMode);
112
118
  // --- Env file args -------------------------------------------------------------------------------------------------------------------
113
119
  const envFileArgs = [];
114
120
  if (envFilePath) {
@@ -128,23 +134,28 @@ export function startContainer(opts) {
128
134
  `${LABEL_PROFILE}=${activeProfile}`,
129
135
  "--label",
130
136
  `${LABEL_RUNTIME_ENV}=${runtimeEnvLabel()}`,
137
+ "--label",
138
+ `${LABEL_GIT_MODE}=${gitMode}`,
131
139
  ];
132
140
  // --- Runtime env vars -----------------------------------------------------------------------------------------------------------------
133
141
  const runtimeEnvArgs = [
134
142
  ...Object.entries(RUNTIME_ENV).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
135
143
  "-e",
136
144
  `TOTOPO_WORKSPACE=${workspaceName}`,
145
+ "-e",
146
+ `TOTOPO_GIT_MODE=${gitMode}`,
137
147
  ];
138
148
  // --- Inspect container state ---------------------------------------------------------------------------------------------------------
139
149
  const info = inspectContainer(containerName);
140
150
  let containerStatus = info?.status ?? null;
141
- // --- Check for shadow, profile, or runtime env mismatch ------------------------------------------------------------------------------
151
+ // --- Check for shadow, profile, runtime env, or git mode mismatch --------------------------------------------------------------------
142
152
  if (info !== null) {
143
153
  const expectedShadowLabel = shadowLabel(expandedShadows);
144
154
  const shadowChanged = info.shadowLabel !== expectedShadowLabel;
145
155
  const profileChanged = info.profileLabel !== activeProfile;
146
156
  const runtimeEnvChanged = info.runtimeEnvLabel !== runtimeEnvLabel();
147
- if (shadowChanged || profileChanged || runtimeEnvChanged) {
157
+ const gitModeChanged = info.gitModeLabel !== gitMode;
158
+ if (shadowChanged || profileChanged || runtimeEnvChanged || gitModeChanged) {
148
159
  stopAndRemoveContainer(containerName);
149
160
  containerStatus = null;
150
161
  if (profileChanged) {
@@ -157,6 +168,10 @@ export function startContainer(opts) {
157
168
  if (!quiet)
158
169
  log.info("Shadow paths changed — recreating container...");
159
170
  }
171
+ else if (gitModeChanged) {
172
+ if (!quiet)
173
+ log.info(`Git mode changed (${info.gitModeLabel || "<unset>"} -> ${gitMode}) — recreating container...`);
174
+ }
160
175
  else {
161
176
  if (!quiet)
162
177
  log.info("Runtime environment updated — recreating container...");
@@ -262,6 +277,8 @@ export async function run(packageDir, ctx, options) {
262
277
  }
263
278
  }
264
279
  const hasGit = existsSync(join(workspaceDir, ".git"));
280
+ // --- Git mode (per-workspace, host-side .lock) ---------------------------------------------------------------------------------------
281
+ const gitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.strict;
265
282
  // --- Start container -----------------------------------------------------------------------------------------------------------------
266
283
  const containerOpts = {
267
284
  containerName,
@@ -273,6 +290,7 @@ export async function run(packageDir, ctx, options) {
273
290
  expandedShadows,
274
291
  envFilePath,
275
292
  hasGit,
293
+ gitMode,
276
294
  shadowPatterns,
277
295
  workspaceName: ctx.workspaceId,
278
296
  ...(options?.noCache !== undefined && { noCache: options.noCache }),
@@ -4,8 +4,10 @@
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { relative } from "node:path";
6
6
  import { cancel, confirm, isCancel, log, multiselect, note, outro, path, select, text } from "@clack/prompts";
7
+ import { GIT_MODE } from "../lib/constants.js";
7
8
  import { countPatternHits } from "../lib/shadows.js";
8
9
  import { buildDefaultTotopoYaml, readTotopoYaml, writeTotopoYaml } from "../lib/totopo-yaml.js";
10
+ import { readGitMode, writeGitMode } from "../lib/workspace-identity.js";
9
11
  // --- Shadow paths menu -------------------------------------------------------------------------------------------------------------------
10
12
  async function shadowPathsMenu(ctx) {
11
13
  const yaml = readTotopoYaml(ctx.workspaceRoot);
@@ -117,6 +119,47 @@ async function removeShadowPatterns(ctx) {
117
119
  log.success(`Removed ${removeSet.size} pattern(s).`);
118
120
  await promptStopContainer(ctx);
119
121
  }
122
+ // --- Git mode menu -----------------------------------------------------------------------------------------------------------------------
123
+ async function gitModeMenu(ctx) {
124
+ const current = readGitMode(ctx.workspaceId) ?? GIT_MODE.strict;
125
+ note("Strict — read-only local, no remote (recommended)\n" +
126
+ "Local — local mutations allowed; remote blocked\n" +
127
+ "Unrestricted — no totopo-enforced restrictions", "Git mode");
128
+ const choice = await select({
129
+ message: "Git mode:",
130
+ options: [
131
+ {
132
+ value: GIT_MODE.strict,
133
+ label: "Strict",
134
+ hint: current === GIT_MODE.strict ? "current · recommended" : "recommended",
135
+ },
136
+ { value: GIT_MODE.local, label: "Local", ...(current === GIT_MODE.local ? { hint: "current" } : {}) },
137
+ {
138
+ value: GIT_MODE.unrestricted,
139
+ label: "Unrestricted",
140
+ ...(current === GIT_MODE.unrestricted ? { hint: "current" } : {}),
141
+ },
142
+ ],
143
+ initialValue: current,
144
+ });
145
+ if (isCancel(choice))
146
+ return;
147
+ if (choice === current)
148
+ return;
149
+ if (choice === GIT_MODE.unrestricted) {
150
+ const confirmed = await confirm({
151
+ message: "Unrestricted mode disables totopo's built-in git restrictions (allows remote push/pull/fetch). Continue?",
152
+ initialValue: false,
153
+ });
154
+ if (isCancel(confirmed) || !confirmed) {
155
+ log.info("Git mode unchanged.");
156
+ return;
157
+ }
158
+ }
159
+ writeGitMode(ctx.workspaceId, choice);
160
+ log.success(`Git mode set to ${choice}.`);
161
+ await promptStopContainer(ctx);
162
+ }
120
163
  // --- Prompt to stop container ------------------------------------------------------------------------------------------------------------
121
164
  async function promptStopContainer(ctx) {
122
165
  const containerName = ctx.containerName;
@@ -160,7 +203,9 @@ async function resetTotopoYaml(ctx) {
160
203
  // --- Manage Workspace submenu ------------------------------------------------------------------------------------------------------------
161
204
  export async function run(ctx) {
162
205
  while (true) {
206
+ const currentGitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.strict;
163
207
  const options = [
208
+ { value: "git-mode", label: "Git mode", hint: `current: ${currentGitMode}` },
164
209
  { value: "shadow-paths", label: "Shadow paths", hint: "manage shadow patterns" },
165
210
  { value: "rebuild", label: "Rebuild container", hint: "force a fresh image build" },
166
211
  { value: "clean-rebuild", label: "Clean rebuild", hint: "fresh build, no cache" },
@@ -172,6 +217,9 @@ export async function run(ctx) {
172
217
  return "back";
173
218
  }
174
219
  switch (action) {
220
+ case "git-mode":
221
+ await gitModeMenu(ctx);
222
+ break;
175
223
  case "shadow-paths":
176
224
  await shadowPathsMenu(ctx);
177
225
  break;
@@ -11,7 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { AGENTS_DIR, CONTAINER_HOME } from "./constants.js";
14
+ import { AGENTS_DIR, CONTAINER_HOME, GIT_MODE } from "./constants.js";
15
15
  export const AGENT_MOUNTS = [
16
16
  {
17
17
  agent: "claude",
@@ -86,8 +86,8 @@ function renderTemplate(template, vars) {
86
86
  /**
87
87
  * Assembles the agent context markdown injected into each supported agent's config dir at session start.
88
88
  */
89
- export function buildAgentContextDocs(hasGit, shadowPatterns) {
90
- const gitSection = loadTemplate(hasGit ? "git-available" : "git-unavailable");
89
+ export function buildAgentContextDocs(hasGit, shadowPatterns, gitMode = GIT_MODE.strict) {
90
+ const gitSection = loadTemplate(hasGit ? `git-mode-${gitMode}` : "git-unavailable");
91
91
  const shadowSection = shadowPatterns && shadowPatterns.length > 0
92
92
  ? renderTemplate(loadTemplate("shadow-paths"), {
93
93
  pattern_list: shadowPatterns.map((p) => `- \`${p}\``).join("\n"),
@@ -29,11 +29,20 @@ export const LABEL_MANAGED = "totopo.managed";
29
29
  export const LABEL_SHADOWS = "totopo.shadows";
30
30
  export const LABEL_PROFILE = "totopo.profile";
31
31
  export const LABEL_RUNTIME_ENV = "totopo.runtime-env";
32
+ export const LABEL_GIT_MODE = "totopo.git-mode";
32
33
  // Built-in profile names (must match keys in buildDefaultTotopoYaml in totopo-yaml.ts)
33
34
  export const PROFILE = {
34
35
  default: "default",
35
36
  extended: "extended",
36
37
  };
38
+ // Git guardrails modes (per-workspace, stored in .lock).
39
+ // Single source of truth lives in templates/runtime-constants.mjs so container-side
40
+ // scripts (startup.mjs, startup-git-mode.mjs) and TS code can both reference the
41
+ // same values without drift. We re-export here so internal code keeps importing
42
+ // from "./constants.js" as before.
43
+ import { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE } from "../../templates/runtime-constants.mjs";
44
+ export { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE };
45
+ export const GIT_MODES = Object.values(GIT_MODE);
37
46
  // Runtime env vars injected into every container via docker run -e
38
47
  export const RUNTIME_ENV = {
39
48
  CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1",
@@ -25,9 +25,9 @@ import { spawnSync } from "node:child_process";
25
25
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { join } from "node:path";
28
- import { confirm, isCancel, log } from "@clack/prompts";
28
+ import { confirm, isCancel, log, note } from "@clack/prompts";
29
29
  import { load as loadYaml } from "js-yaml";
30
- import { AGENTS_DIR, CONTAINER_STARTUP, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
30
+ import { AGENTS_DIR, CONTAINER_STARTUP, GIT_MODE, GIT_WRAPPER_SOURCE, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
31
31
  import { safeRmSync } from "./safe-rm.js";
32
32
  import { buildDefaultTotopoYaml, readTotopoYaml, slugifyForWorkspaceId, validateWorkspaceId, writeTotopoYaml, } from "./totopo-yaml.js";
33
33
  import { findTotopoYamlDir, getWorkspacesBaseDir, initWorkspaceDir, LOCK_KEYS } from "./workspace-identity.js";
@@ -390,6 +390,38 @@ function migrateRemoveLastCliUpdate() {
390
390
  }
391
391
  }
392
392
  }
393
+ /**
394
+ * Pre-v3.4.0: Add the git_mode field to .lock files. Existing workspaces are kept on
395
+ * git_mode=local to preserve their behavior; new workspaces created via initWorkspaceDir
396
+ * default to git_mode=strict instead. Idempotent - skips files that already have the field.
397
+ * Prints a one-time clack note() when any workspace was newly migrated, so users discover
398
+ * the new feature without rediscovering it on every subsequent startup. Returns the count
399
+ * for testing purposes; the registered Migration entry ignores it.
400
+ */
401
+ export function migrateAddGitMode() {
402
+ const baseDir = getWorkspacesBaseDir();
403
+ if (!existsSync(baseDir))
404
+ return 0;
405
+ let migrated = 0;
406
+ for (const entry of readdirSync(baseDir)) {
407
+ const lockPath = join(baseDir, entry, LOCK_FILE);
408
+ try {
409
+ const content = readFileSync(lockPath, "utf8");
410
+ if (content.includes(`${LOCK_KEYS.gitMode}=`))
411
+ continue;
412
+ const trimmed = content.endsWith("\n") ? content : `${content}\n`;
413
+ writeFileSync(lockPath, `${trimmed}${LOCK_KEYS.gitMode}=${GIT_MODE.local}\n`);
414
+ migrated++;
415
+ }
416
+ catch {
417
+ // unreadable -- skip, will surface as a broken workspace elsewhere
418
+ }
419
+ }
420
+ if (migrated > 0) {
421
+ note(`totopo v3.4.0 introduces git modes for workspaces.\nYour existing workspace${migrated > 1 ? "s have" : " has"} been kept on 'local' (today's behavior — local commits allowed, remote blocked).\nA new 'strict' mode is available (read-only, recommended for new agent sessions).\nSwitch via the totopo menu > Manage Workspace > Git mode.`, "Git modes");
422
+ }
423
+ return migrated;
424
+ }
393
425
  /**
394
426
  * v3.2.1 and earlier: Remove deprecated fields from totopo.yaml.
395
427
  * - schema_version: redundant, totopo validates with the bundled JSON schema at runtime
@@ -461,6 +493,7 @@ function buildMigrations(cwd, skipAnyConfirmations) {
461
493
  description: "Remove deprecated fields (schema_version, name, yaml-language-server) from totopo.yaml",
462
494
  run: () => migrateRemoveDeprecatedYamlFields(cwd),
463
495
  },
496
+ { from: "v3.4.0", description: "Add git_mode=local to .lock files (preserves pre-v3.4.0 behavior)", run: migrateAddGitMode },
464
497
  ];
465
498
  }
466
499
  /** Run all migrations in order. Called early in bin/totopo.js startup. */
@@ -488,5 +521,17 @@ export function isImageStale(containerName) {
488
521
  const bubblewrapCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/bwrap"], { stdio: "pipe" });
489
522
  if (bubblewrapCheck.status !== 0)
490
523
  return true;
524
+ // v3.4.0: git read-only wrapper baked in for strict git mode
525
+ const wrapperCheck = spawnSync("docker", ["exec", containerName, "test", "-x", GIT_WRAPPER_SOURCE], {
526
+ stdio: "pipe",
527
+ });
528
+ if (wrapperCheck.status !== 0)
529
+ return true;
530
+ // v3.4.0: runtime-constants module imported by startup-git-mode.mjs
531
+ const constantsCheck = spawnSync("docker", ["exec", containerName, "test", "-f", "/home/devuser/runtime-constants.mjs"], {
532
+ stdio: "pipe",
533
+ });
534
+ if (constantsCheck.status !== 0)
535
+ return true;
491
536
  return false;
492
537
  }
@@ -5,12 +5,13 @@
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
- import { AGENTS_DIR, CONTAINER_NAME_PREFIX, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
8
+ import { AGENTS_DIR, CONTAINER_NAME_PREFIX, GIT_MODE, GIT_MODES, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
9
9
  import { readTotopoYaml } from "./totopo-yaml.js";
10
10
  /** Maps LockFile field names to their corresponding keys written in the .lock file. */
11
11
  export const LOCK_KEYS = {
12
12
  workspaceRoot: "root",
13
13
  activeProfile: "profile",
14
+ gitMode: "git_mode",
14
15
  };
15
16
  /** Reverse lookup: file key → LockFile field name, used during parsing. */
16
17
  const FILE_KEY_TO_FIELD = Object.fromEntries(Object.entries(LOCK_KEYS).map(([field, key]) => [key, field]));
@@ -53,6 +54,7 @@ function parseLockFile(workspaceId) {
53
54
  return {
54
55
  workspaceRoot: partial.workspaceRoot,
55
56
  activeProfile: partial.activeProfile ?? PROFILE.default,
57
+ gitMode: partial.gitMode ?? GIT_MODE.strict,
56
58
  };
57
59
  }
58
60
  catch {
@@ -70,32 +72,48 @@ function writeLockFileInternal(workspaceId, data) {
70
72
  export function readLockFile(workspaceId) {
71
73
  return parseLockFile(workspaceId)?.workspaceRoot ?? null;
72
74
  }
73
- /** Write a workspace's lock file with the owning workspace root path. Preserves active profile. */
75
+ /** Write a workspace's lock file with the owning workspace root path. Preserves active profile and git mode. */
74
76
  export function writeLockFile(workspaceId, workspaceRoot) {
75
77
  const existing = parseLockFile(workspaceId);
76
78
  writeLockFileInternal(workspaceId, {
77
79
  workspaceRoot,
78
80
  activeProfile: existing?.activeProfile ?? PROFILE.default,
81
+ gitMode: existing?.gitMode ?? GIT_MODE.strict,
79
82
  });
80
83
  }
81
84
  /** Read the active profile name. Returns null if lock file is missing. */
82
85
  export function readActiveProfile(workspaceId) {
83
86
  return parseLockFile(workspaceId)?.activeProfile ?? null;
84
87
  }
85
- /** Write the active profile name. Preserves workspace root path. */
88
+ /** Write the active profile name. Preserves workspace root path and git mode. */
86
89
  export function writeActiveProfile(workspaceId, profile) {
87
90
  const existing = parseLockFile(workspaceId);
88
91
  if (!existing)
89
92
  return;
90
93
  writeLockFileInternal(workspaceId, { ...existing, activeProfile: profile });
91
94
  }
95
+ /** Read the active git mode. Returns null if lock file is missing. Coerces unknown values to strict. */
96
+ export function readGitMode(workspaceId) {
97
+ const parsed = parseLockFile(workspaceId);
98
+ if (!parsed)
99
+ return null;
100
+ const value = parsed.gitMode;
101
+ return GIT_MODES.includes(value) ? value : GIT_MODE.strict;
102
+ }
103
+ /** Write the active git mode. Preserves workspace root path and active profile. */
104
+ export function writeGitMode(workspaceId, gitMode) {
105
+ const existing = parseLockFile(workspaceId);
106
+ if (!existing)
107
+ return;
108
+ writeLockFileInternal(workspaceId, { ...existing, gitMode });
109
+ }
92
110
  // --- Workspace directory initialization --------------------------------------------------------------------------------------------------
93
111
  /** Initialize ~/.totopo/workspaces/<workspace_id>/ with lock file and subdirs. */
94
- export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = PROFILE.default) {
112
+ export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = PROFILE.default, gitMode = GIT_MODE.strict) {
95
113
  const dir = getWorkspaceDir(workspaceId);
96
114
  mkdirSync(join(dir, AGENTS_DIR), { recursive: true });
97
115
  mkdirSync(join(dir, SHADOWS_DIR), { recursive: true });
98
- writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile });
116
+ writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile, gitMode });
99
117
  }
100
118
  // --- Listing -----------------------------------------------------------------------------------------------------------------------------
101
119
  /** List all registered workspace IDs (directories with a .lock file) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.3.3",
3
+ "version": "3.4.0-rc-1",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -90,10 +90,20 @@ RUN groupadd --gid 1001 devuser && \
90
90
  chown -R devuser:devuser /home/devuser
91
91
 
92
92
  # ---------------------------------------------------------------------------
93
- # Layer 9 — Bake startup script into image
93
+ # Layer 9 — Bake startup script + git mode helper + shared constants into image
94
94
  # ---------------------------------------------------------------------------
95
95
  COPY startup.mjs /home/devuser/startup.mjs
96
- RUN chown devuser:devuser /home/devuser/startup.mjs
96
+ COPY startup-git-mode.mjs /home/devuser/startup-git-mode.mjs
97
+ COPY runtime-constants.mjs /home/devuser/runtime-constants.mjs
98
+ RUN chown devuser:devuser /home/devuser/startup.mjs /home/devuser/startup-git-mode.mjs /home/devuser/runtime-constants.mjs
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Layer 10 — Bake git read-only wrapper into image
102
+ # Symlinked to /usr/local/bin/git by startup.mjs when git_mode=strict.
103
+ # ---------------------------------------------------------------------------
104
+ RUN mkdir -p /usr/local/share/totopo
105
+ COPY git-readonly-wrapper.mjs /usr/local/share/totopo/git-readonly
106
+ RUN chmod +x /usr/local/share/totopo/git-readonly
97
107
 
98
108
  WORKDIR /workspace
99
109
 
@@ -0,0 +1,9 @@
1
+ ## Git availability
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).
6
+
7
+ totopo enforces this by:
8
+ - Blocking remote access (push, pull, fetch, clone).
9
+ - Leaving local git operations unrestricted.
@@ -0,0 +1,10 @@
1
+ ## Git availability
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.
6
+
7
+ totopo enforces this by:
8
+ - Blocking git commands that would modify the repository (attempts return a clear error).
9
+ - Blocking remote access (push, pull, fetch, clone).
10
+ - Allowing read-only inspection commands (e.g. `git status`, `git log`, `git show`, `git diff`, `git blame`, `git branch --list`, `git rev-parse`) — use these freely when you need to understand the repo state.
@@ -0,0 +1,3 @@
1
+ ## Git availability
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.
@@ -2,6 +2,4 @@
2
2
 
3
3
  Git is **not available** — no `.git` directory was found in the workspace root.
4
4
 
5
- Remote access is also **blocked container-wide** by design (`protocol.allow = never` in `/etc/gitconfig`).
6
-
7
5
  If git operations are needed, ask the user to run them on the host.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // git-readonly-wrapper.mjs -- Read-only git wrapper for strict mode
4
+ // Baked into the container image at /usr/local/share/totopo/git-readonly.
5
+ // startup.mjs symlinks /usr/local/bin/git -> this file when git_mode=strict.
6
+ // PATH puts /usr/local/bin before /usr/bin so this is invoked when an agent
7
+ // runs `git`. Allowed subcommands forward to /usr/bin/git unchanged; blocked
8
+ // ones print a clear error and exit non-zero.
9
+ //
10
+ // Threat model: guardrails for cooperative agents, not adversarial containment.
11
+ // /usr/bin/git remains accessible by absolute path; remote ops stay blocked at
12
+ // the gitconfig protocol layer regardless of which binary is invoked.
13
+ //
14
+ // The classifier is exported for unit testing. Pure Node built-ins, no deps.
15
+ // =============================================================================
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import { realpathSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const REAL_GIT = "/usr/bin/git";
22
+
23
+ // -- Read-only global actions: print and exit, no subcommand needed -----------
24
+ const READ_ONLY_GLOBAL_ACTIONS = new Set(["--version", "--help", "-h", "--html-path", "--man-path", "--info-path"]);
25
+
26
+ // -- Global flags that consume the next argv element as their value -----------
27
+ const TWO_ARG_GLOBALS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--attr-source"]);
28
+
29
+ // -- Subcommands that are unconditionally read-only ---------------------------
30
+ const READ_SUBCOMMANDS = new Set([
31
+ "status",
32
+ "log",
33
+ "show",
34
+ "diff",
35
+ "blame",
36
+ "reflog",
37
+ "rev-parse",
38
+ "rev-list",
39
+ "describe",
40
+ "cat-file",
41
+ "name-rev",
42
+ "fsck",
43
+ "shortlog",
44
+ "grep",
45
+ "count-objects",
46
+ "var",
47
+ "help",
48
+ "version",
49
+ "ls-files",
50
+ "ls-tree",
51
+ "merge-base",
52
+ "for-each-ref",
53
+ "show-ref",
54
+ "symbolic-ref",
55
+ "check-ignore",
56
+ "check-attr",
57
+ "check-mailmap",
58
+ "check-ref-format",
59
+ "whatchanged",
60
+ "cherry",
61
+ "range-diff",
62
+ "verify-commit",
63
+ "verify-tag",
64
+ "annotate",
65
+ "instaweb",
66
+ "diff-tree",
67
+ "diff-index",
68
+ "diff-files",
69
+ ]);
70
+
71
+ // -- branch/tag flags that indicate mutation; block on any match --------------
72
+ const BRANCH_MUTATING_FLAGS = new Set([
73
+ "-d",
74
+ "-D",
75
+ "-m",
76
+ "-M",
77
+ "-c",
78
+ "-C",
79
+ "--delete",
80
+ "--move",
81
+ "--copy",
82
+ "--set-upstream",
83
+ "--set-upstream-to",
84
+ "--unset-upstream",
85
+ "--edit-description",
86
+ "--create-reflog",
87
+ ]);
88
+
89
+ const TAG_MUTATING_FLAGS = new Set([
90
+ "-d",
91
+ "-D",
92
+ "-m",
93
+ "-a",
94
+ "-s",
95
+ "-u",
96
+ "-f",
97
+ "--delete",
98
+ "--message",
99
+ "--annotate",
100
+ "--sign",
101
+ "--local-user",
102
+ "--cleanup",
103
+ "--force",
104
+ ]);
105
+
106
+ // -- config flags: explicit read/write markers + flags that consume the next arg ----
107
+ const CONFIG_READ_FLAGS = new Set([
108
+ "--get",
109
+ "--get-all",
110
+ "--get-regexp",
111
+ "--get-urlmatch",
112
+ "--get-color",
113
+ "--get-colorbool",
114
+ "--list",
115
+ "-l",
116
+ "--show-origin",
117
+ "--show-scope",
118
+ "--name-only",
119
+ ]);
120
+ const CONFIG_WRITE_FLAGS = new Set([
121
+ "--unset",
122
+ "--unset-all",
123
+ "--add",
124
+ "--replace-all",
125
+ "--remove-section",
126
+ "--rename-section",
127
+ "-e",
128
+ "--edit",
129
+ ]);
130
+ // Flags that take their next arg as a value -- skip the value when counting tokens.
131
+ // --file/-f/--blob = scope; --type = value coercion; --default = fallback for --get.
132
+ const CONFIG_TWO_ARG_FLAGS = new Set(["--file", "-f", "--blob", "--type", "--default"]);
133
+
134
+ // -- remote: block these subactions, allow the rest (default = list) ----------
135
+ const REMOTE_MUTATING_ACTIONS = new Set(["add", "remove", "rm", "rename", "set-url", "prune", "update", "set-head", "set-branches"]);
136
+
137
+ // -- stash: only these subactions are read-only; bare `git stash` mutates -----
138
+ const STASH_READ_ACTIONS = new Set(["list", "show"]);
139
+
140
+ // -- worktree: only `list` is read-only ---------------------------------------
141
+ const WORKTREE_READ_ACTIONS = new Set(["list"]);
142
+
143
+ // -- notes: list/show are read; bare `git notes` defaults to list -------------
144
+ const NOTES_READ_ACTIONS = new Set(["list", "show", "get-ref"]);
145
+
146
+ // -- bisect: log/view are read; everything else mutates the bisect state ------
147
+ const BISECT_READ_ACTIONS = new Set(["log", "view"]);
148
+
149
+ /**
150
+ * Walk argv left-to-right and find the first non-flag token (the subcommand).
151
+ * Skips git's global option flags, including ones whose value is in the next argv slot.
152
+ * Returns { subcmd, rest } where rest is the args after the subcommand.
153
+ */
154
+ export function findSubcommand(argv) {
155
+ let i = 0;
156
+ while (i < argv.length) {
157
+ const arg = argv[i];
158
+ if (!arg.startsWith("-")) {
159
+ return { subcmd: arg, rest: argv.slice(i + 1) };
160
+ }
161
+ if (TWO_ARG_GLOBALS.has(arg)) {
162
+ // -c key=value, -C path, --git-dir path, etc.
163
+ i += 2;
164
+ continue;
165
+ }
166
+ // --foo=bar, --bare, --no-pager, --paginate, etc. - one-arg
167
+ i += 1;
168
+ }
169
+ return { subcmd: null, rest: [] };
170
+ }
171
+
172
+ /**
173
+ * Classify a git invocation under strict mode.
174
+ * Returns { allow: true } or { allow: false, reason: string }.
175
+ * Pure function - exported for unit testing without forking.
176
+ */
177
+ export function classify(argv) {
178
+ // Read-only global actions (--version, --help, etc.) short-circuit.
179
+ for (const a of argv) {
180
+ if (READ_ONLY_GLOBAL_ACTIONS.has(a)) return { allow: true };
181
+ if (a.startsWith("--list-cmds=")) return { allow: true };
182
+ }
183
+
184
+ const { subcmd, rest } = findSubcommand(argv);
185
+
186
+ // Bare `git` (no subcommand) prints usage - read-only.
187
+ if (subcmd === null) return { allow: true };
188
+
189
+ if (READ_SUBCOMMANDS.has(subcmd)) return { allow: true };
190
+
191
+ if (subcmd === "branch") {
192
+ for (const a of rest) {
193
+ if (BRANCH_MUTATING_FLAGS.has(a)) return blocked(`branch ${a}`);
194
+ }
195
+ return { allow: true };
196
+ }
197
+
198
+ if (subcmd === "tag") {
199
+ for (const a of rest) {
200
+ if (TAG_MUTATING_FLAGS.has(a)) return blocked(`tag ${a}`);
201
+ }
202
+ return { allow: true };
203
+ }
204
+
205
+ if (subcmd === "config") {
206
+ // Explicit write flags take precedence over everything else.
207
+ for (const a of rest) {
208
+ if (CONFIG_WRITE_FLAGS.has(a)) return blocked(`config ${a}`);
209
+ }
210
+ // Explicit read flags are an unconditional allow.
211
+ for (const a of rest) {
212
+ if (CONFIG_READ_FLAGS.has(a)) return { allow: true };
213
+ }
214
+ // Otherwise count non-flag tokens after the subcommand:
215
+ // `config <key>` -> 1 token -> read
216
+ // `config <key> <value>` -> 2 tokens -> write
217
+ // Scope flags (--system, --global, ...) are flags and don't count.
218
+ let nonFlagCount = 0;
219
+ for (let i = 0; i < rest.length; i++) {
220
+ const a = rest[i];
221
+ if (a.startsWith("-")) {
222
+ if (CONFIG_TWO_ARG_FLAGS.has(a)) i++; // also consume its value
223
+ continue;
224
+ }
225
+ nonFlagCount++;
226
+ if (nonFlagCount >= 2) return blocked("config (write)");
227
+ }
228
+ return { allow: true };
229
+ }
230
+
231
+ if (subcmd === "stash") {
232
+ const action = firstNonFlag(rest);
233
+ if (action !== null && STASH_READ_ACTIONS.has(action)) return { allow: true };
234
+ return blocked(action ? `stash ${action}` : "stash");
235
+ }
236
+
237
+ if (subcmd === "remote") {
238
+ const action = firstNonFlag(rest);
239
+ if (action !== null && REMOTE_MUTATING_ACTIONS.has(action)) return blocked(`remote ${action}`);
240
+ return { allow: true };
241
+ }
242
+
243
+ if (subcmd === "worktree") {
244
+ const action = firstNonFlag(rest);
245
+ if (action !== null && WORKTREE_READ_ACTIONS.has(action)) return { allow: true };
246
+ return blocked(action ? `worktree ${action}` : "worktree");
247
+ }
248
+
249
+ if (subcmd === "notes") {
250
+ const action = firstNonFlag(rest);
251
+ if (action === null || NOTES_READ_ACTIONS.has(action)) return { allow: true };
252
+ return blocked(`notes ${action}`);
253
+ }
254
+
255
+ if (subcmd === "bisect") {
256
+ const action = firstNonFlag(rest);
257
+ if (action !== null && BISECT_READ_ACTIONS.has(action)) return { allow: true };
258
+ return blocked(action ? `bisect ${action}` : "bisect");
259
+ }
260
+
261
+ return blocked(subcmd);
262
+ }
263
+
264
+ function firstNonFlag(args) {
265
+ for (const a of args) {
266
+ if (!a.startsWith("-")) return a;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ function blocked(label) {
272
+ return {
273
+ allow: false,
274
+ reason: `git: '${label}' blocked in strict mode (read-only). Switch git mode via 'totopo' menu > Manage Workspace > Git mode.`,
275
+ };
276
+ }
277
+
278
+ // Skip the runtime invocation when imported (e.g. by the test suite). The wrapper is normally
279
+ // invoked through the symlink at /usr/local/bin/git, so a literal argv[1] vs import.meta.url
280
+ // comparison wouldn't match -- realpathSync resolves the symlink before comparing.
281
+ function detectIsMain() {
282
+ if (!process.argv[1]) return false;
283
+ try {
284
+ return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+ const isMain = detectIsMain();
290
+
291
+ if (isMain) {
292
+ const result = classify(process.argv.slice(2));
293
+ if (!result.allow) {
294
+ process.stderr.write(`${result.reason}\n`);
295
+ process.exit(1);
296
+ }
297
+ const child = spawnSync(REAL_GIT, process.argv.slice(2), { stdio: "inherit" });
298
+ process.exit(child.status ?? 1);
299
+ }
@@ -0,0 +1,18 @@
1
+ // =============================================================================
2
+ // runtime-constants.mjs -- Constants shared between container-side runtime
3
+ // scripts and the totopo CLI build.
4
+ //
5
+ // Container-side scripts (startup.mjs, startup-git-mode.mjs) cannot import
6
+ // from src/lib/constants.ts since the TS source isn't shipped to the image.
7
+ // Keep this file plain ESM and import it from both sides; src/lib/constants.ts
8
+ // re-exports the values so TS callers stay typed.
9
+ // =============================================================================
10
+
11
+ export const GIT_MODE = Object.freeze({
12
+ strict: "strict",
13
+ local: "local",
14
+ unrestricted: "unrestricted",
15
+ });
16
+
17
+ export const GIT_WRAPPER_PATH = "/usr/local/bin/git";
18
+ export const GIT_WRAPPER_SOURCE = "/usr/local/share/totopo/git-readonly";
@@ -0,0 +1,143 @@
1
+ // =============================================================================
2
+ // startup-git-mode.mjs -- Git mode application + verification for startup.mjs
3
+ // Baked into the container image alongside startup.mjs at /home/devuser/.
4
+ //
5
+ // Strict / local / unrestricted are read from the TOTOPO_GIT_MODE env var injected by
6
+ // dev.ts. As root we apply the requested state to /etc/gitconfig and the
7
+ // /usr/local/bin/git symlink; as devuser we only verify that the state already
8
+ // matches (the previous root invocation is what put it there).
9
+ // Must use only Node.js built-ins -- no external packages available in container.
10
+ // =============================================================================
11
+
12
+ import { execSync } from "node:child_process";
13
+ import { lstatSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
14
+ import { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE } from "./runtime-constants.mjs";
15
+
16
+ const VALID_GIT_MODES = Object.values(GIT_MODE);
17
+
18
+ function lstatExists(path) {
19
+ try {
20
+ lstatSync(path);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function isWrapperSymlinkInPlace() {
28
+ if (!lstatExists(GIT_WRAPPER_PATH)) return false;
29
+ try {
30
+ const st = lstatSync(GIT_WRAPPER_PATH);
31
+ if (!st.isSymbolicLink()) return false;
32
+ return readlinkSync(GIT_WRAPPER_PATH) === GIT_WRAPPER_SOURCE;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function removeWrapperIfPresent() {
39
+ if (!lstatExists(GIT_WRAPPER_PATH)) return;
40
+ try {
41
+ unlinkSync(GIT_WRAPPER_PATH);
42
+ } catch {
43
+ // Already gone or inaccessible -- subsequent verification will catch it
44
+ }
45
+ }
46
+
47
+ function applyAsRoot(gitMode, protocolValue, fail) {
48
+ try {
49
+ execSync(`git config --system protocol.allow ${protocolValue}`, { stdio: "pipe" });
50
+ } catch {
51
+ fail("git mode", `failed to set protocol.allow=${protocolValue}`);
52
+ }
53
+
54
+ if (gitMode === GIT_MODE.strict) {
55
+ if (!isWrapperSymlinkInPlace()) {
56
+ // Remove any pre-existing /usr/local/bin/git (stale symlink, leftover binary)
57
+ // so symlinkSync below doesn't EEXIST.
58
+ removeWrapperIfPresent();
59
+ try {
60
+ symlinkSync(GIT_WRAPPER_SOURCE, GIT_WRAPPER_PATH);
61
+ } catch {
62
+ fail("git wrapper", `failed to install ${GIT_WRAPPER_PATH}`);
63
+ }
64
+ }
65
+ return;
66
+ }
67
+ removeWrapperIfPresent();
68
+ }
69
+
70
+ function verifyProtocol(gitMode, protocolValue, run, ok, fail) {
71
+ const gitProtocol = run("git config --system protocol.allow");
72
+ if (gitProtocol === protocolValue) {
73
+ ok("git mode", `${gitMode} (protocol.allow=${protocolValue})`);
74
+ } else {
75
+ fail("git mode", `expected protocol.allow=${protocolValue}, found ${gitProtocol ?? "<unset>"}`);
76
+ }
77
+ }
78
+
79
+ function verifyWrapper(gitMode, ok, fail, skip) {
80
+ if (gitMode === GIT_MODE.strict) {
81
+ if (isWrapperSymlinkInPlace()) {
82
+ ok("git read-only wrapper", `${GIT_WRAPPER_PATH} -> ${GIT_WRAPPER_SOURCE}`);
83
+ } else {
84
+ fail("git read-only wrapper", `not installed at ${GIT_WRAPPER_PATH}`);
85
+ }
86
+ return;
87
+ }
88
+ if (lstatExists(GIT_WRAPPER_PATH)) {
89
+ fail("git read-only wrapper", `should be absent in ${gitMode} mode`);
90
+ } else {
91
+ skip("git read-only wrapper", `not active in ${gitMode} mode`);
92
+ }
93
+ }
94
+
95
+ function verifyStrictWrapperRejects(ok, fail) {
96
+ // Probe the wrapper with a representative mutating command. The classifier
97
+ // rejects before forking real git, so we should see our marker on stderr.
98
+ let probeStderr = "";
99
+ let probeExit = 0;
100
+ try {
101
+ execSync(`${GIT_WRAPPER_PATH} commit -m probe`, { stdio: ["pipe", "pipe", "pipe"] });
102
+ } catch (err) {
103
+ probeStderr = (err.stderr ?? "").toString();
104
+ probeExit = err.status ?? 1;
105
+ }
106
+ if (probeExit !== 0 && probeStderr.includes("blocked in strict mode")) {
107
+ ok("strict wrapper rejects mutation", "'git commit' blocked");
108
+ } else {
109
+ fail("strict wrapper rejects mutation", "wrapper did not produce the expected error");
110
+ }
111
+ }
112
+
113
+ function verifyRemoteBlocked(gitMode, ok, fail, skip) {
114
+ if (gitMode === GIT_MODE.unrestricted) {
115
+ skip("remote push", "allowed in unrestricted mode (network probe skipped)");
116
+ return;
117
+ }
118
+ try {
119
+ execSync("/usr/bin/git -C /workspace push", { stdio: "pipe" });
120
+ fail("remote push blocked", "git push succeeded -- remote access is NOT blocked");
121
+ } catch {
122
+ ok("remote push blocked", "remote push not possible");
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Apply (when root) and verify the git mode requested via TOTOPO_GIT_MODE.
128
+ * Reports through the caller-provided ok/fail/skip helpers so all output flows
129
+ * through the main startup script's section formatting and error counter.
130
+ */
131
+ export function checkGitMode({ ok, fail, skip, run, isRoot }) {
132
+ const gitMode = VALID_GIT_MODES.includes(process.env.TOTOPO_GIT_MODE) ? process.env.TOTOPO_GIT_MODE : GIT_MODE.strict;
133
+ const protocolValue = gitMode === GIT_MODE.unrestricted ? "always" : "never";
134
+
135
+ if (isRoot) {
136
+ applyAsRoot(gitMode, protocolValue, fail);
137
+ }
138
+
139
+ verifyProtocol(gitMode, protocolValue, run, ok, fail);
140
+ verifyWrapper(gitMode, ok, fail, skip);
141
+ if (gitMode === GIT_MODE.strict) verifyStrictWrapperRejects(ok, fail);
142
+ verifyRemoteBlocked(gitMode, ok, fail, skip);
143
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { execSync } from "node:child_process";
10
10
  import { readFileSync, writeFileSync } from "node:fs";
11
+ import { checkGitMode } from "./startup-git-mode.mjs";
11
12
 
12
13
  const run = (cmd) => {
13
14
  try {
@@ -84,19 +85,8 @@ if (idOutput?.includes("uid=1001")) {
84
85
  fail("non-root user", "devuser not found or wrong uid -- container is misconfigured");
85
86
  }
86
87
 
87
- const gitProtocol = run("git config --system protocol.allow");
88
- if (gitProtocol === "never") {
89
- ok("git remote block", "protocol.allow = never");
90
- } else {
91
- fail("git remote block", "not set -- rebuild the container");
92
- }
93
-
94
- try {
95
- execSync("/usr/bin/git -C /workspace push", { stdio: "pipe" });
96
- fail("push blocked", "git push succeeded -- remote access is NOT blocked");
97
- } catch {
98
- ok("push blocked", "remote push not possible");
99
- }
88
+ // -- Git mode (strict / local / unrestricted) - applied + verified by separate module --
89
+ checkGitMode({ ok, fail, skip, run, isRoot });
100
90
 
101
91
  // -- AI tools -----------------------------------------------------------------
102
92
  section("AI tools");
@@ -1,5 +0,0 @@
1
- ## Git availability
2
-
3
- Git is fully available for local operations (commit, branch, log, diff, status, etc.).
4
-
5
- Remote access (push, pull, fetch, clone) is **blocked at the system level** by design — `protocol.allow = never` is enforced in `/etc/gitconfig` and cannot be overridden without root. This is a deliberate security boundary: the container has no access to remote repositories. Ask the user to run any remote git operations from the host.