// Git for the Discord Sage (row 24, Jason's word 2026-09-16: "Sage will need // to have git tooling to commit, push, pull, etc. for the shared-signals // repo"). Four fixed verbs plus one record helper, each a child process // with a fixed argument list, run only in a root that is marked writable // and carries a `git` key. No verb takes free-form arguments and nothing // here goes through a shell. // // git_status(root) branch, ahead/behind, changed paths // git_commit(root, message, paths) stage exactly those paths, commit as // the seat, push at once (D6) // git_pull(root) pull --ff-only origin // git_push(root) push origin , never --force // reserve_id(root, prefix, title) vault protocol only: the next free // record id, appended to the registry // // Fences shared by the verbs, decided here and tested without a remote: // - the current branch must be the one the binding names; a detached // head, another branch, a merge, rebase or cherry-pick in progress, // or a conflicted path refuses every verb // - a commit refuses an index that already holds staged changes (Jason's // own work in the same clone is never swept in), a message that is // empty or over COMMIT_MESSAGE_MAX characters, a path that is not a // vetted regular file under the root, and an empty result // - the commit author is the seat (`git.author`), with a trailer naming // the Discord requester by the name the binding gives, never an id // - a child runs at most GIT_TIMEOUT_MS; its output is cut at // GIT_OUTPUT_CAP and masked before it reaches the model or a record // - children see an allowlisted environment: no global or system git // config (so the host's own credential helpers never run), terminal // prompts off, one credential helper (bin/git-credential.mjs) that // reads the seat's token file only during a push or pull, and the // seat's name for the vault lock tool // // The token is never read here, never printed, never on a command line: // the helper receives the file's path in its environment and hands the // value to git on the credential protocol's stdout, nowhere else. // // The `vault` protocol is the shared-signals record protocol // (tools/vault_lock.py, tools/validate_vault.py, docs/ID-REGISTRY.txt in // that repository): a commit first checks that no other owner locks a // staged path and that the validator passes; writes take the clone lock // around the replace; reserve_id appends the next free id. Those scripts // belong to that repository; this file calls them as fixed argv inside // the root and nowhere else. import { spawnSync } from "node:child_process"; import { existsSync, lstatSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; export const GIT_TOOL_NAMES = Object.freeze(["git_status", "git_commit", "git_pull", "git_push"]); export const RESERVE_TOOL_NAME = "reserve_id"; export const GIT_PROTOCOLS = Object.freeze(["vault"]); export const GIT_TIMEOUT_MS = 60_000; export const GIT_OUTPUT_CAP = 4096; export const COMMIT_MESSAGE_MAX = 500; export const COMMIT_PATHS_MAX = 50; export const STATUS_PATHS_MAX = 100; export const GIT_TOKEN_FILE_ENV = "MOSAIC_DISCORD_GIT_TOKEN_FILE"; export const GIT_USERNAME_ENV = "MOSAIC_DISCORD_GIT_USERNAME"; export const CREDENTIAL_HELPER = fileURLToPath(new URL("../bin/git-credential.mjs", import.meta.url)); export const VAULT_PREFIXES = Object.freeze(["BUS", "PRJ", "SS", "DEC", "REF"]); export const VAULT_REGISTRY = "docs/ID-REGISTRY.txt"; export const VAULT_LOCK_TOOL = "tools/vault_lock.py"; export const VAULT_VALIDATOR = "tools/validate_vault.py"; export const RESERVE_TITLE_MAX = 200; export const GIT_REFUSAL = Object.freeze({ NO_GIT: "that root has no git", DETACHED: "the work tree is on a detached head; Jason resolves it from the terminal", BRANCH: "the work tree is not on the branch the binding names; Jason resolves it from the terminal", IN_PROGRESS: "a merge, rebase or cherry-pick is in progress; Jason resolves it from the terminal", CONFLICT: "the work tree has conflicted paths; Jason resolves them from the terminal", INDEX_DIRTY: "the index already holds staged changes that are not yours; Jason resolves them from the terminal", BAD_MESSAGE: `message must be one to ${COMMIT_MESSAGE_MAX} characters of plain text`, BAD_PATHS: `paths must name one to ${COMMIT_PATHS_MAX} files under the root`, NO_REQUESTER: "the requester of this message is unknown; the commit is refused", LOCKED: "a staged path is locked by another contributor", INVALID: "the record validator failed; fix the records before committing", NOTHING: "nothing to commit: those paths have no changes", COMMIT_FAILED: "git refused the commit", NON_FF: "origin has moved in a way that is not a fast-forward; Jason reconciles from the terminal", DIRTY: "local changes would be overwritten by the pull; commit or ask Jason first", PULL_FAILED: "git could not pull", PUSH_FAILED: "git could not push", BAD_PREFIX: `prefix must be one of ${VAULT_PREFIXES.join(", ")}`, BAD_TITLE: `title must be one to ${RESERVE_TITLE_MAX} characters on one line`, RESERVE_FAILED: "the id could not be reserved", NO_PROTOCOL: "that root has no record protocol; ids are not reserved there", TIMEOUT: "git took too long and was stopped", }); export class GitRefusal extends Error { constructor(reason, detail = null) { super(detail ? `${reason}: ${detail}` : reason); this.reason = reason; this.detail = detail; } } const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/; const IDENTITY = /^[a-z0-9][a-z0-9._-]{0,63}$/; const AUTHOR = /^([^<>\r\n]{1,64}?) <([^<>\s@]+@[^<>\s@]+)>$/; // Validate a root's `git` key. `real` is the root's real path; it must be a // git work tree (a .git directory or file). The token file must be a // private file now so a bad binding fails the start, not the first push. export function loadGitConfig(raw, where, real) { if (!isObject(raw)) throw new Error(`${where}: not an object`); for (const k of Object.keys(raw)) { if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`); } if (typeof raw.branch !== "string" || !BRANCH_NAME.test(raw.branch) || raw.branch.includes("..")) throw new Error(`${where}.branch: must be a branch name`); if (typeof raw.identity !== "string" || !IDENTITY.test(raw.identity)) throw new Error(`${where}.identity: must match ${IDENTITY}`); if (typeof raw.tokenFile !== "string" || !raw.tokenFile.startsWith("/") || raw.tokenFile.includes("\0") || raw.tokenFile.includes("'")) throw new Error(`${where}.tokenFile: must be an absolute path`); if (typeof raw.author !== "string" || !AUTHOR.test(raw.author)) throw new Error(`${where}.author: must be "Name "`); const [, name, email] = raw.author.match(AUTHOR); if (raw.protocol !== undefined && !GIT_PROTOCOLS.includes(raw.protocol)) throw new Error(`${where}.protocol: must be one of ${GIT_PROTOCOLS.join(", ")}`); checkPrivateFile(raw.tokenFile, `${where}.tokenFile`); if (typeof real === "string") { let st = null; try { st = lstatSync(join(real, ".git")); } catch { // handled below } if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: ${real} is not a git work tree`); } if (process.execPath.includes("'") || CREDENTIAL_HELPER.includes("'")) throw new Error(`${where}: the node or helper path holds a quote and cannot be a credential helper`); return Object.freeze({ branch: raw.branch, identity: raw.identity, tokenFile: raw.tokenFile, author: Object.freeze({ name: name.trim(), email }), protocol: raw.protocol ?? null, }); } function checkPrivateFile(path, what) { let st; try { st = lstatSync(path); } catch { throw new Error(`${what}: not found: ${path}`); } if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`); if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`); if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`); if (st.size === 0) throw new Error(`${what}: is empty: ${path}`); } // The environment every child sees. No global or system git configuration, // so the host's own helpers (gh, the fleet helper) never run for the seat; // one helper of our own, named with the node binary that runs the // connector; the seat's author identity; the seat's name for the lock // tool. The token file's path is added only for the verbs that talk to // origin. export function gitEnv(git, { remote = false } = {}) { const config = [ ["credential.helper", `!'${process.execPath}' '${CREDENTIAL_HELPER}'`], ["user.name", git.author.name], ["user.email", git.author.email], ["core.askPass", ""], ["push.default", "nothing"], ]; const env = { PATH: process.env.PATH || "/usr/bin:/bin", HOME: process.env.HOME || "/nonexistent", LANG: "C.UTF-8", LC_ALL: "C.UTF-8", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_COUNT: String(config.length), MOSAIC_AGENT_NAME: git.identity, VAULT_LOCK_OWNER: git.identity, [GIT_USERNAME_ENV]: git.identity, }; config.forEach(([k, v], i) => { env[`GIT_CONFIG_KEY_${i}`] = k; env[`GIT_CONFIG_VALUE_${i}`] = v; }); if (remote) env[GIT_TOKEN_FILE_ENV] = git.tokenFile; return env; } // Anything that could carry a credential is masked before it goes further: // a userinfo part in a url, and token-shaped words. export function maskSecrets(text) { return String(text ?? "") .replace(/(https?:\/\/)[^/\s@]*@/g, "$1@") .replace(/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, "") .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, ""); } function cap(text) { const s = maskSecrets(text).replace(/\0/g, ""); return s.length > GIT_OUTPUT_CAP ? `${s.slice(0, GIT_OUTPUT_CAP)}\n… cut at ${GIT_OUTPUT_CAP} characters` : s; } // Run one child with a fixed argv. Returns {status, stdout, stderr}; a // timeout or spawn failure is a refusal, never an exception. function run(cmd, args, cwd, env, spawn) { const r = spawn(cmd, args, { cwd, env, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }); if (r.error && r.error.code === "ETIMEDOUT") throw new GitRefusal(GIT_REFUSAL.TIMEOUT, `${cmd} ${args[0] || ""}`); if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || r.error.message})`); return { status: r.status, stdout: cap(r.stdout || ""), stderr: cap(r.stderr || "") }; } function git(root, gitCfg, args, { remote = false, spawn = spawnSync } = {}) { return run("git", args, root.real, gitEnv(gitCfg, { remote }), spawn); } function firstLines(text, n = 5) { return text.split("\n").filter((l) => l.trim().length > 0).slice(0, n).join("\n"); } // The guard every verb runs first: on the named branch, nothing in // progress, no conflicts. function guard(root, gitCfg, deps) { const head = git(root, gitCfg, ["symbolic-ref", "--short", "-q", "HEAD"], deps); if (head.status !== 0) throw new GitRefusal(GIT_REFUSAL.DETACHED); const branch = head.stdout.trim(); if (branch !== gitCfg.branch) throw new GitRefusal(GIT_REFUSAL.BRANCH, `on ${branch}, binding names ${gitCfg.branch}`); const dir = git(root, gitCfg, ["rev-parse", "--absolute-git-dir"], deps); if (dir.status !== 0) throw new GitRefusal(GIT_REFUSAL.NO_GIT, firstLines(dir.stderr, 1)); const gitDir = dir.stdout.trim(); for (const marker of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG"]) { if (existsSync(join(gitDir, marker))) throw new GitRefusal(GIT_REFUSAL.IN_PROGRESS, marker); } const conflicts = git(root, gitCfg, ["diff", "--name-only", "--diff-filter=U"], deps); if (conflicts.status === 0 && conflicts.stdout.trim().length > 0) throw new GitRefusal(GIT_REFUSAL.CONFLICT, firstLines(conflicts.stdout, 3)); return branch; } // Parse `git status --porcelain=v2 --branch` into plain data. export function parseStatus(text) { const out = { branch: null, upstream: null, ahead: null, behind: null, changed: [], untracked: [], conflicts: [], truncated: false }; let count = 0; const push = (list, item) => { if (count >= STATUS_PATHS_MAX) { out.truncated = true; return; } list.push(item); count += 1; }; for (const line of text.split("\n")) { if (line.startsWith("# branch.head ")) out.branch = line.slice(14).trim(); else if (line.startsWith("# branch.upstream ")) out.upstream = line.slice(18).trim(); else if (line.startsWith("# branch.ab ")) { const m = line.match(/\+(\d+) -(\d+)/); if (m) { out.ahead = Number(m[1]); out.behind = Number(m[2]); } } else if (line.startsWith("1 ") || line.startsWith("2 ")) { const f = line.split(" "); const path = line.startsWith("2 ") ? f.slice(9).join(" ").split("\t")[0] : f.slice(8).join(" "); push(out.changed, { path, state: f[1] }); } else if (line.startsWith("? ")) push(out.untracked, line.slice(2)); else if (line.startsWith("u ")) push(out.conflicts, line.split(" ").slice(10).join(" ")); } return out; } export function gitStatus(root, deps = {}) { const gitCfg = root.git; guard(root, gitCfg, deps); const r = git(root, gitCfg, ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], deps); if (r.status !== 0) throw new GitRefusal(GIT_REFUSAL.NO_GIT, firstLines(r.stderr, 1)); return { root: root.name, ...parseStatus(r.stdout) }; } // The vault protocol's pre-commit checks, run as fixed argv inside the root. function vaultChecks(root, gitCfg, rels, deps) { const env = gitEnv(gitCfg); const check = run("python3", [VAULT_LOCK_TOOL, "check", "--owner", gitCfg.identity, "--", ...rels], root.real, env, deps.spawn || spawnSync); if (check.status !== 0) throw new GitRefusal(GIT_REFUSAL.LOCKED, firstLines(check.stderr || check.stdout, 3)); const validate = run("python3", [VAULT_VALIDATOR], root.real, env, deps.spawn || spawnSync); if (validate.status !== 0) throw new GitRefusal(GIT_REFUSAL.INVALID, firstLines(validate.stderr || validate.stdout, 5)); } // Stage exactly `rels` (already vetted by the caller as regular files under // the root), commit as the seat with the requester trailer, then push. A // push that fails is reported in the result, not thrown: the commit is // real and the next commit's push carries it (D6). export function gitCommit(root, { message, rels, requester }, deps = {}) { const gitCfg = root.git; if (typeof message !== "string" || message.includes("\0") || message.trim().length === 0 || message.length > COMMIT_MESSAGE_MAX) throw new GitRefusal(GIT_REFUSAL.BAD_MESSAGE); if (!Array.isArray(rels) || rels.length === 0 || rels.length > COMMIT_PATHS_MAX || rels.some((p) => typeof p !== "string" || p.length === 0 || p.startsWith("-"))) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS); if (typeof requester !== "string" || !/^[^\r\n:<>]{1,100}$/.test(requester)) throw new GitRefusal(GIT_REFUSAL.NO_REQUESTER); const branch = guard(root, gitCfg, deps); const staged = git(root, gitCfg, ["diff", "--cached", "--name-only"], deps); if (staged.status !== 0) throw new GitRefusal(GIT_REFUSAL.COMMIT_FAILED, firstLines(staged.stderr, 1)); if (staged.stdout.trim().length > 0) throw new GitRefusal(GIT_REFUSAL.INDEX_DIRTY, firstLines(staged.stdout, 3)); if (gitCfg.protocol === "vault") vaultChecks(root, gitCfg, rels, deps); const add = git(root, gitCfg, ["add", "--", ...rels], deps); if (add.status !== 0) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS, firstLines(add.stderr, 2)); const unstage = () => git(root, gitCfg, ["reset", "-q", "--", ...rels], deps); const now = git(root, gitCfg, ["diff", "--cached", "--name-only"], deps); if (now.stdout.trim().length === 0) { unstage(); throw new GitRefusal(GIT_REFUSAL.NOTHING); } const commit = git(root, gitCfg, ["commit", "-q", "--no-status", "-m", message.trim(), "--trailer", `Requested-by: ${requester.trim()}`], deps); if (commit.status !== 0) { unstage(); throw new GitRefusal(GIT_REFUSAL.COMMIT_FAILED, firstLines(commit.stderr || commit.stdout, 5)); } const rev = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps); const hash = rev.status === 0 ? rev.stdout.trim() : null; const committed = now.stdout.trim().split("\n"); let pushed = false; let pushError = null; try { pushOnce(root, gitCfg, branch, deps); pushed = true; } catch (err) { if (!(err instanceof GitRefusal)) throw err; pushError = err.message; } return { root: root.name, branch, hash, paths: committed, requester: requester.trim(), pushed, pushError }; } function pushOnce(root, gitCfg, branch, deps) { const r = git(root, gitCfg, ["push", "origin", `${branch}:${branch}`], { ...deps, remote: true }); if (r.status !== 0) throw new GitRefusal(GIT_REFUSAL.PUSH_FAILED, firstLines(r.stderr || r.stdout, 3)); return /Everything up-to-date/.test(r.stderr) ? { upToDate: true } : { upToDate: false }; } export function gitPush(root, deps = {}) { const gitCfg = root.git; const branch = guard(root, gitCfg, deps); const before = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim(); const r = pushOnce(root, gitCfg, branch, deps); return { root: root.name, branch, hash: before, pushed: true, upToDate: r.upToDate }; } export function gitPull(root, deps = {}) { const gitCfg = root.git; const branch = guard(root, gitCfg, deps); const before = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim(); const r = git(root, gitCfg, ["pull", "--ff-only", "--no-rebase", "origin", branch], { ...deps, remote: true }); if (r.status !== 0) { const text = `${r.stderr}\n${r.stdout}`; if (/fast-forward|diverg|Not possible/i.test(text)) throw new GitRefusal(GIT_REFUSAL.NON_FF, firstLines(r.stderr || r.stdout, 2)); if (/would be overwritten|local changes/i.test(text)) throw new GitRefusal(GIT_REFUSAL.DIRTY, firstLines(r.stderr || r.stdout, 3)); throw new GitRefusal(GIT_REFUSAL.PULL_FAILED, firstLines(r.stderr || r.stdout, 3)); } const after = git(root, gitCfg, ["rev-parse", "--short", "HEAD"], deps).stdout.trim(); return { root: root.name, branch, from: before, to: after, updated: before !== after }; } // vault protocol: reserve the next free id for a prefix. The tool appends // the registry line itself; the caller stages `docs/ID-REGISTRY.txt` with // the record in the next commit. export function reserveId(root, { prefix, title }, deps = {}) { const gitCfg = root.git; if (gitCfg.protocol !== "vault") throw new GitRefusal(GIT_REFUSAL.NO_PROTOCOL); if (typeof prefix !== "string" || !VAULT_PREFIXES.includes(prefix)) throw new GitRefusal(GIT_REFUSAL.BAD_PREFIX); if (typeof title !== "string" || title.trim().length === 0 || title.length > RESERVE_TITLE_MAX || /[\r\n\0]/.test(title) || title.startsWith("-")) throw new GitRefusal(GIT_REFUSAL.BAD_TITLE); guard(root, gitCfg, deps); const r = run("python3", [VAULT_LOCK_TOOL, "reserve", prefix, "--owner", gitCfg.identity, "--title", title.trim()], root.real, gitEnv(gitCfg, { remote: true }), deps.spawn || spawnSync); const id = r.stdout.trim().split("\n").pop() || ""; if (r.status !== 0 || !/^[A-Z]{2,3}-\d{3,}$/.test(id)) throw new GitRefusal(GIT_REFUSAL.RESERVE_FAILED, firstLines(r.stderr || r.stdout, 3)); const note = firstLines(r.stderr, 1) || null; return { root: root.name, id, registry: VAULT_REGISTRY, note }; } // vault protocol: hold the clone lock for `rel` while `fn` runs. A lock // another owner holds refuses with that owner named; the lock is released // whatever `fn` does. export function withVaultLock(root, rel, fn, deps = {}) { const gitCfg = root.git; if (!gitCfg || gitCfg.protocol !== "vault") return fn(); const env = gitEnv(gitCfg); const spawn = deps.spawn || spawnSync; const lock = run("python3", [VAULT_LOCK_TOOL, "lock", "--owner", gitCfg.identity, "--ttl", "300", "--", rel], root.real, env, spawn); if (lock.status !== 0) throw new GitRefusal(GIT_REFUSAL.LOCKED, firstLines(lock.stderr || lock.stdout, 2)); try { return fn(); } finally { run("python3", [VAULT_LOCK_TOOL, "unlock", "--owner", gitCfg.identity, "--", rel], root.real, env, spawn); } }