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 +14 -4
- package/dist/commands/dev.js +27 -9
- package/dist/commands/workspace.js +48 -0
- package/dist/lib/agent-context.js +3 -3
- package/dist/lib/constants.js +9 -0
- package/dist/lib/migrate-to-latest.js +47 -2
- package/dist/lib/workspace-identity.js +23 -5
- package/package.json +1 -1
- package/templates/Dockerfile +12 -2
- package/templates/context/git-mode-local.md +9 -0
- package/templates/context/git-mode-strict.md +10 -0
- package/templates/context/git-mode-unrestricted.md +3 -0
- package/templates/context/git-unavailable.md +0 -2
- package/templates/git-readonly-wrapper.mjs +299 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/startup-git-mode.mjs +143 -0
- package/templates/startup.mjs +3 -13
- package/templates/context/git-available.md +0 -5
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
|
|
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
|
-
|
|
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
|
|
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/
|
package/dist/commands/dev.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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 ?
|
|
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"),
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
package/templates/Dockerfile
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
}
|
package/templates/startup.mjs
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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.
|