diff --git a/bin/gstack-post b/bin/gstack-post new file mode 100755 index 000000000..a49f77422 --- /dev/null +++ b/bin/gstack-post @@ -0,0 +1,34 @@ +#!/usr/bin/env bun +/** + * gstack-post — post PR/MR and issue text from agent-written files. + * + * gstack-post {pr-comment|issue-comment|reply|pr-title|pr-body} --body-file F|--title-file F + * gstack-post pr-create --base [--head ] [--draft] --title-file F --body-file F + * gstack-post issue-create --title-file F --body-file F + * + * Scans the exact bytes it sends (lib/redact-engine.ts) and passes every value + * to gh/glab as an argv element or on stdin, never through a shell string. + * Exit: 0 posted · 1 HIGH refused · 2 MEDIUM needs --confirm · + * 3 gh/glab failed · 64 usage. Logic and full usage: lib/gstack-post.ts. + */ +import * as fs from "fs"; +import * as path from "path"; +import { spawnSync } from "child_process"; +import { runPost } from "../lib/gstack-post"; + +const code = runPost(process.argv.slice(2), { + run(cmd, args, input) { + const r = spawnSync(cmd, args, { + input: input ?? "", + encoding: "utf8", + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error) : "") }; + }, + readFile: (p) => fs.readFileSync(p, "utf8"), + out: (t) => process.stdout.write(t), + err: (t) => process.stderr.write(t), +}, path.join(import.meta.dir, "gstack-config")); +process.exitCode = code; diff --git a/lib/gstack-post.ts b/lib/gstack-post.ts new file mode 100644 index 000000000..7c0f7e17d --- /dev/null +++ b/lib/gstack-post.ts @@ -0,0 +1,338 @@ +/** + * gstack-post: the one write path for PR and issue text (B4). + * + * Every value reaches `gh`/`glab` as its own argv element in `--flag=` + * form, or on stdin, never through a shell string. The text comes from files + * the agent wrote with its file tool; the helper reads each file once, scans + * those exact bytes with the shared redaction engine, and sends the same bytes. + * + * Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused; + * 2 MEDIUM findings need confirmation, printed as `RULE:` lines plus a + * `TOKEN:` that binds the bytes, destination, operation and findings; + * 3 `gh`/`glab` failed; 64 usage or invalid input. Nothing is posted on any + * non-zero exit. bin/gstack-post is the CLI; tests drive `runPost` with a fake + * runner so the logic is covered on every platform. + */ +import { createHash } from "crypto"; +import { scan, type Finding, type RepoVisibility } from "./redact-engine"; +import { canonicalRemote } from "./remote-identity"; + +export const OPS = ["pr-comment", "issue-comment", "reply", "pr-title", "pr-body", "pr-create", "issue-create"] as const; +export type Op = (typeof OPS)[number]; +export type HostKind = "github" | "gitlab"; + +export interface RunResult { status: number; stdout: string; stderr: string } +export interface PostEnv { + /** Runs `cmd` with an argv array (no shell). `input` goes to stdin. */ + run(cmd: string, args: string[], input?: string): RunResult; + readFile(path: string): string; + out(text: string): void; + err(text: string): void; +} + +export const EXIT = { posted: 0, refused: 1, confirm: 2, cliFailed: 3, usage: 64 } as const; + +export const USAGE = `usage: gstack-post [] [options] + + gstack-post pr-comment --body-file F + gstack-post issue-comment --body-file F + gstack-post reply --to --body-file F (GitHub only) + gstack-post pr-title --title-file F + gstack-post pr-body --body-file F + gstack-post pr-create --base [--head ] [--draft] --title-file F --body-file F + gstack-post issue-create --title-file F --body-file F + + / is a number or a URL of this repository on the same host. + Options: --host github|gitlab (default: detected from the origin remote) + --repo-visibility public|private|unknown (default: config, then gh/glab) + --confirm post bytes whose MEDIUM findings the user confirmed + + Write the text files with your file-write tool; never put the text in a + shell command. The helper scans the exact bytes it sends. + + Exit: 0 posted · 1 HIGH finding refused · 2 MEDIUM findings need + confirmation (RULE:/TOKEN: lines on stdout) · 3 gh/glab failed · 64 usage +`; + +interface Request { + op: Op; + target?: string; + to?: string; + base?: string; + head?: string; + draft: boolean; + titleFile?: string; + bodyFile?: string; + host?: HostKind; + visibility?: RepoVisibility; + confirm?: string; +} + +class UsageError extends Error {} + +const VALUE_FLAGS = new Set(["--to", "--base", "--head", "--title-file", "--body-file", "--host", "--repo-visibility", "--confirm"]); + +function parseArgs(argv: string[]): Request { + const [op, ...rest] = argv; + if (!OPS.includes(op as Op)) throw new UsageError(op ? `unknown operation "${op}"` : "missing operation"); + const flags = new Map(); + const positional: string[] = []; + let draft = false; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]!; + if (a === "--draft") { draft = true; continue; } + const eq = a.indexOf("="); + const name = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a; + if (VALUE_FLAGS.has(name)) { + const value = name === a ? rest[++i] : a.slice(eq + 1); + if (value === undefined || value === "") throw new UsageError(`${name} needs a value`); + if (flags.has(name)) throw new UsageError(`${name} given twice`); + flags.set(name, value); + continue; + } + if (a.startsWith("-")) throw new UsageError(`unknown option "${a}"`); + positional.push(a); + } + const host = flags.get("--host"); + if (host !== undefined && host !== "github" && host !== "gitlab") throw new UsageError("--host must be github or gitlab"); + const vis = flags.get("--repo-visibility"); + if (vis !== undefined && !["public", "private", "unknown"].includes(vis)) throw new UsageError("--repo-visibility must be public, private or unknown"); + const req: Request = { + op: op as Op, draft, + target: positional[0], to: flags.get("--to"), base: flags.get("--base"), head: flags.get("--head"), + titleFile: flags.get("--title-file"), bodyFile: flags.get("--body-file"), + host: host as HostKind | undefined, visibility: vis as RepoVisibility | undefined, confirm: flags.get("--confirm"), + }; + const creates = req.op === "pr-create" || req.op === "issue-create"; + if (positional.length > (creates ? 0 : 1)) throw new UsageError(`unexpected argument "${positional[creates ? 0 : 1]}"`); + if (!creates && !req.target) throw new UsageError(`${req.op} needs a target (a number or a URL)`); + const needsTitle = req.op === "pr-title" || creates; + const needsBody = req.op !== "pr-title"; + if (needsTitle !== !!req.titleFile) throw new UsageError(needsTitle ? `${req.op} needs --title-file` : `${req.op} takes no --title-file`); + if (needsBody !== !!req.bodyFile) throw new UsageError(needsBody ? `${req.op} needs --body-file` : `${req.op} takes no --body-file (use --title-file)`); + if ((req.op === "reply") !== !!req.to) throw new UsageError(req.op === "reply" ? "reply needs --to " : "--to applies to reply only"); + if (req.to !== undefined && !/^[1-9][0-9]*$/.test(req.to)) throw new UsageError("--to must be a numeric comment id"); + if ((req.op === "pr-create") !== !!req.base) throw new UsageError(req.op === "pr-create" ? "pr-create needs --base " : "--base applies to pr-create only"); + if (req.op !== "pr-create" && (req.head || req.draft)) throw new UsageError("--head and --draft apply to pr-create only"); + return req; +} + +interface Remote { host: string; segments: string[]; kind: HostKind } + +function hostedId(url: string) { + const id = canonicalRemote(url.trim()); + return id.hosted && id.segments.length >= 2 ? { host: id.canonical.split("/")[0]!, segments: id.segments } : undefined; +} + +/** + * The origin remote's host and path. A hostname that names neither GitHub nor + * GitLab (an enterprise host, a proxy) is resolved by asking gh, then glab, + * for the repository URL. + */ +function resolveRemote(env: PostEnv, forced?: HostKind): Remote { + const r = env.run("git", ["remote", "get-url", "origin"]); + const origin = r.status === 0 ? hostedId(r.stdout) : undefined; + if (!origin) throw new UsageError("cannot read a hosted origin remote (git remote get-url origin)"); + const named: HostKind | undefined = origin.host.includes("github") ? "github" : origin.host.includes("gitlab") ? "gitlab" : undefined; + if (named && (!forced || forced === named)) return { ...origin, kind: named }; + for (const kind of forced ? [forced] : (["github", "gitlab"] as const)) { + const q = kind === "github" + ? env.run("gh", ["repo", "view", "--json", "url", "--jq", ".url"]) + : env.run("glab", ["repo", "view", "--output", "json"]); + let url = q.status === 0 ? q.stdout : ""; + if (kind === "gitlab" && url) { try { url = String(JSON.parse(url).web_url ?? ""); } catch { url = ""; } } + const id = url ? hostedId(url) : undefined; + if (id) return { ...id, kind }; + } + if (forced) return { ...origin, kind: forced }; + throw new UsageError(`cannot tell whether ${origin.host} is GitHub or GitLab; pass --host github|gitlab`); +} + +/** A number, or a URL of this repository on the remote's host, as a number. */ +function targetNumber(target: string, op: Op, remote: Remote): string { + if (/^[1-9][0-9]*$/.test(target)) return target; + let url: URL; + try { url = new URL(target); } catch { throw new UsageError(`target "${target}" is neither a number nor a URL`); } + if (url.protocol !== "https:" && url.protocol !== "http:") throw new UsageError("target URL must be http(s)"); + if (url.hostname.toLowerCase() !== remote.host) throw new UsageError(`target URL host ${url.hostname} is not this repository's host ${remote.host}`); + const parts = url.pathname.split("/").filter(Boolean); + const issue = op === "issue-comment"; + const markers = remote.kind === "github" ? [issue ? "issues" : "pull"] : ["-", issue ? "issues" : "merge_requests"]; + const at = parts.findIndex((p, i) => markers.every((m, j) => parts[i + j] === m)); + const repo = at < 0 ? [] : parts.slice(0, at); + const num = at < 0 ? undefined : parts[at + markers.length]; + const same = repo.length === remote.segments.length && repo.every((s, i) => s.toLowerCase() === remote.segments[i]!.toLowerCase()); + if (!same || !num || !/^[1-9][0-9]*$/.test(num)) throw new UsageError(`target URL is not ${issue ? "an issue" : "a pull/merge request"} of ${remote.segments.join("/")}`); + return num; +} + +function readText(env: PostEnv, file: string, what: "title" | "body"): string { + let text: string; + try { text = env.readFile(file); } catch { throw new UsageError(`cannot read ${what} file ${file}; write it with your file-write tool first`); } + if (what === "title") { + text = text.replace(/\r?\n$/, ""); + if (/[\r\n]/.test(text)) throw new UsageError("title file must hold one line"); + } + if (!text.trim()) throw new UsageError(`${what} file ${file} is empty; write the text first`); + return text; +} + +function visibility(env: PostEnv, req: Request, kind: HostKind, configBin: string): RepoVisibility { + if (req.visibility) return req.visibility; + const norm = (s: string) => { + const v = s.trim().toLowerCase(); + return v === "public" || v === "private" || v === "unknown" ? v : undefined; + }; + const cfg = env.run(configBin, ["get", "redact_repo_visibility"]); + const fromConfig = cfg.status === 0 ? norm(cfg.stdout) : undefined; + if (fromConfig) return fromConfig; + if (kind === "github") { + const gh = env.run("gh", ["repo", "view", "--json", "visibility", "--jq", ".visibility"]); + return (gh.status === 0 && norm(gh.stdout)) || "unknown"; + } + const gl = env.run("glab", ["repo", "view", "--output", "json"]); + if (gl.status !== 0) return "unknown"; + try { return norm(String(JSON.parse(gl.stdout).visibility ?? "")) ?? "unknown"; } catch { return "unknown"; } +} + +type Part = "title" | "body"; +interface Located { part: Part; finding: Finding } + +function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined) { + const found: Located[] = []; + let oversize = false; + for (const [part, text] of parts) { + const result = scan(text, { repoVisibility: vis, ...(selfEmail ? { selfEmail } : {}) }); + oversize ||= result.oversize; + for (const finding of result.findings) found.push({ part, finding }); + } + return { found, oversize }; +} + +export function confirmationToken(fields: Record, parts: Array<[Part, string]>, medium: Located[]): string { + const h = createHash("sha256"); + h.update(JSON.stringify(fields)); + for (const [part, text] of parts) h.update(`\0${part}\0${Buffer.byteLength(text)}\0`).update(text); + h.update(JSON.stringify(medium.map(m => [m.part, m.finding.id, m.finding.line, m.finding.col]))); + return h.digest("hex").slice(0, 24); +} + +/** The argv that sends one operation; `stdin` carries the body where the CLI reads it from "-". */ +function command(kind: HostKind, op: Op, n: string | undefined, req: Request, title?: string, body?: string): { cmd: string; args: string[]; stdin?: string } { + if (kind === "github") { + switch (op) { + case "pr-comment": return { cmd: "gh", args: ["pr", "comment", n!, "--body-file=-"], stdin: body }; + case "issue-comment": return { cmd: "gh", args: ["issue", "comment", n!, "--body-file=-"], stdin: body }; + case "reply": return { cmd: "gh", args: ["api", "--method=POST", `repos/{owner}/{repo}/pulls/${n}/comments/${req.to}/replies`, "--field=body=@-"], stdin: body }; + case "pr-title": return { cmd: "gh", args: ["pr", "edit", n!, `--title=${title}`] }; + case "pr-body": return { cmd: "gh", args: ["pr", "edit", n!, "--body-file=-"], stdin: body }; + case "pr-create": return { cmd: "gh", args: ["pr", "create", `--base=${req.base}`, ...(req.head ? [`--head=${req.head}`] : []), `--title=${title}`, "--body-file=-", ...(req.draft ? ["--draft"] : [])], stdin: body }; + case "issue-create": return { cmd: "gh", args: ["issue", "create", `--title=${title}`, "--body-file=-"], stdin: body }; + } + } + switch (op) { + case "pr-comment": return { cmd: "glab", args: ["mr", "note", n!, `--message=${body}`] }; + case "issue-comment": return { cmd: "glab", args: ["issue", "note", n!, `--message=${body}`] }; + case "pr-title": return { cmd: "glab", args: ["mr", "update", n!, `--title=${title}`] }; + case "pr-body": return { cmd: "glab", args: ["mr", "update", n!, `--description=${body}`] }; + case "pr-create": return { cmd: "glab", args: ["mr", "create", `--target-branch=${req.base}`, ...(req.head ? [`--source-branch=${req.head}`] : []), `--title=${title}`, `--description=${body}`, "--yes", ...(req.draft ? ["--draft"] : [])] }; + case "issue-create": return { cmd: "glab", args: ["issue", "create", `--title=${title}`, `--description=${body}`, "--yes"] }; + default: throw new UsageError(`${op} is not supported on GitLab`); + } +} + +/** `gh pr edit` can fail on the retired projectCards GraphQL field; the REST PATCH sends the same bytes. */ +function restEdit(op: Op, n: string, title?: string, body?: string) { + return op === "pr-title" + ? { cmd: "gh", args: ["api", "--method=PATCH", `repos/{owner}/{repo}/pulls/${n}`, `--raw-field=title=${title}`] } + : { cmd: "gh", args: ["api", "--method=PATCH", `repos/{owner}/{repo}/pulls/${n}`, "--field=body=@-", "--silent"], stdin: body }; +} + +function readBackTitle(env: PostEnv, kind: HostKind, n: string): string | undefined { + const r = kind === "github" + ? env.run("gh", ["pr", "view", n, "--json", "title", "--jq", ".title"]) + : env.run("glab", ["mr", "view", n, "--output", "json"]); + if (r.status !== 0) return undefined; + if (kind === "github") return r.stdout.replace(/\r?\n$/, ""); + try { return String(JSON.parse(r.stdout).title); } catch { return undefined; } +} + +function send(env: PostEnv, kind: HostKind, req: Request, n: string | undefined, title?: string, body?: string): number { + let c = command(kind, req.op, n, req, title, body); + let r = env.run(c.cmd, c.args, c.stdin); + if (r.status !== 0 && kind === "github" && (req.op === "pr-title" || req.op === "pr-body") && /projectCards/.test(r.stderr)) { + c = restEdit(req.op, n!, title, body); + r = env.run(c.cmd, c.args, c.stdin); + } + if (r.status !== 0) { + env.err(`gstack-post: ${c.cmd} failed (exit ${r.status}); nothing more was sent.\n${r.stderr}`); + return EXIT.cliFailed; + } + if (req.op === "pr-title") { + for (let attempt = 0; readBackTitle(env, kind, n!) !== title; attempt++) { + if (attempt === 1) { + env.err("gstack-post: the PR title still differs from the title file after one retry.\n"); + return EXIT.cliFailed; + } + if (env.run(c.cmd, c.args, c.stdin).status !== 0) { + env.err("gstack-post: retrying the title edit failed.\n"); + return EXIT.cliFailed; + } + } + } + if (r.stdout.trim()) env.out(r.stdout.endsWith("\n") ? r.stdout : r.stdout + "\n"); + env.out(`POSTED: ${req.op}${n ? ` ${n}` : ""}\n`); + return EXIT.posted; +} + +export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config"): number { + if (argv.length === 0 || ["help", "--help", "-h"].includes(argv[0]!)) { + (argv.length === 0 ? env.err : env.out)(USAGE); + return argv.length === 0 ? EXIT.usage : EXIT.posted; + } + try { + const req = parseArgs(argv); + const remote = resolveRemote(env, req.host); + if (req.op === "reply" && remote.kind !== "github") throw new UsageError("reply is supported on GitHub only"); + const n = req.target === undefined ? undefined : targetNumber(req.target, req.op, remote); + const title = req.titleFile ? readText(env, req.titleFile, "title") : undefined; + const body = req.bodyFile ? readText(env, req.bodyFile, "body") : undefined; + const parts: Array<[Part, string]> = []; + if (title !== undefined) parts.push(["title", title]); + if (body !== undefined) parts.push(["body", body]); + + const vis = visibility(env, req, remote.kind, configBin); + const email = env.run("git", ["config", "user.email"]); + const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined); + env.out(`REPO_VISIBILITY: ${vis}\n`); + const line = (m: Located) => `RULE: ${m.finding.id} LINE: ${m.finding.line} PART: ${m.part}\n`; + const high = found.filter(m => m.finding.severity === "HIGH"); + if (oversize || high.length) { + for (const m of high) env.out(`HIGH ${line(m)}`); + env.err(oversize + ? "gstack-post: refused, the text is too large to scan safely. Nothing was posted.\n" + : `gstack-post: refused, HIGH finding (${[...new Set(high.map(m => m.finding.id))].join(", ")}). Remove the value at its source and rotate it if it is a credential; no confirmation can post it.\n`); + return EXIT.refused; + } + for (const m of found.filter(f => f.finding.severity === "LOW" || f.finding.severity === "WARN")) env.err(`note: ${m.finding.severity} ${line(m)}`); + const medium = found.filter(m => m.finding.severity === "MEDIUM"); + if (medium.length) { + const token = confirmationToken( + { op: req.op, host: remote.kind, repo: [remote.host, ...remote.segments].join("/"), target: n ?? null, to: req.to ?? null, base: req.base ?? null, head: req.head ?? null, draft: req.draft }, + parts, medium); + if (req.confirm !== token) { + for (const m of medium) env.out(line(m)); + env.out(`TOKEN: ${token}\n`); + env.err(req.confirm + ? "gstack-post: the text, destination or findings changed since that token; nothing was posted. Ask about these findings again.\n" + : "gstack-post: MEDIUM findings; nothing was posted. Ask the user about each finding; to post these exact bytes, rerun with --confirm . Any edit needs a new scan.\n"); + return EXIT.confirm; + } + } + return send(env, remote.kind, req, n, title, body); + } catch (e) { + if (!(e instanceof UsageError)) throw e; + env.err(`gstack-post: ${e.message} (gstack-post --help shows usage)\n`); + return EXIT.usage; + } +} diff --git a/test/gstack-post-cli.test.ts b/test/gstack-post-cli.test.ts new file mode 100644 index 000000000..38e135e69 --- /dev/null +++ b/test/gstack-post-cli.test.ts @@ -0,0 +1,132 @@ +/** + * gstack-post executable against stub gh/glab (B4). The stubs record each + * argv element and stdin byte for byte, proving the real spawn layer sends + * values as arguments: shell metacharacters in the text never run, and a + * title that looks like a flag arrives as one `--title=` element. + * POSIX only (shebang stubs); test/gstack-post.test.ts covers the logic on + * Windows. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const POST = path.join(ROOT, "bin", "gstack-post"); +let tmp = ""; +let repo = ""; +let stubs = ""; +let state = ""; +let gitEnv: Record = {}; + +function stub(name: string) { + const file = path.join(stubs, name); + fs.writeFileSync(file, `#!/usr/bin/env bash +log="$STUB_LOG/${name}.$(date +%s%N)" +for a in "$@"; do printf '%s\\0' "$a"; done > "$log.args" +cat > "$log.stdin" +case "$1 $2" in + "repo view") echo "\${STUB_VISIBILITY:-PRIVATE}" ;; + "pr view") cat "$STUB_LOG/title" 2>/dev/null ;; + "pr edit") for a in "$@"; do case "$a" in --title=*) printf '%s\\n' "\${a#--title=}" > "$STUB_LOG/title" ;; esac; done ;; + "pr create") echo "https://github.com/acme/widget/pull/77" ;; +esac +exit 0 +`, { mode: 0o755 }); +} + +function calls(log: string, name: string) { + return fs.readdirSync(log).filter(f => f.startsWith(name + ".") && f.endsWith(".args")).sort().map(f => ({ + args: fs.readFileSync(path.join(log, f), "utf8").split("\0").slice(0, -1), + stdin: fs.readFileSync(path.join(log, f.replace(/\.args$/, ".stdin")), "utf8"), + })).filter(c => c.args[1] !== "view"); +} + +function post(args: string[], extraEnv: Record = {}) { + const log = fs.mkdtempSync(path.join(tmp, "log-")); + const r = spawnSync(process.execPath, [POST, ...args], { + cwd: repo, encoding: "utf8", timeout: 30_000, + env: { ...process.env, ...gitEnv, PATH: `${stubs}${path.delimiter}${process.env.PATH}`, STUB_LOG: log, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, ...extraEnv }, + }); + return { ...r, log }; +} + +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-post-cli-")); + repo = path.join(tmp, "repo"); + stubs = path.join(tmp, "bin"); + state = path.join(tmp, "state"); + fs.mkdirSync(repo); + fs.mkdirSync(stubs); + fs.mkdirSync(state); + // The fixture ignores the machine's git config: a global url.insteadOf + // rewrite would change what `git remote get-url origin` reports. + fs.writeFileSync(path.join(tmp, "gitconfig"), ""); + gitEnv = { GIT_CONFIG_GLOBAL: path.join(tmp, "gitconfig"), GIT_CONFIG_NOSYSTEM: "1" }; + const git = (args: string[]) => spawnSync("git", args, { cwd: repo, timeout: 10_000, env: { ...process.env, ...gitEnv } }); + git(["init", "-q"]); + git(["remote", "add", "origin", "git@github.com:acme/widget.git"]); + git(["config", "user.email", "me@acme-corp.io"]); + stub("gh"); + stub("glab"); +}); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +function write(name: string, text: string) { + const file = path.join(repo, name); + fs.writeFileSync(file, text); + return file; +} + +describe("gstack-post executable", () => { + test("body bytes arrive on stdin unchanged and nothing in them runs", () => { + const body = "Fixed in `touch pwned-backtick`. $(touch pwned-subst) \"q\" 'single' \\ end\n"; + const r = post(["pr-comment", "12", "--body-file", write("body.md", body)]); + expect(r.status).toBe(0); + expect(calls(r.log, "gh")).toEqual([{ args: ["pr", "comment", "12", "--body-file=-"], stdin: body }]); + expect(fs.existsSync(path.join(repo, "pwned-backtick"))).toBe(false); + expect(fs.existsSync(path.join(repo, "pwned-subst"))).toBe(false); + }); + + test("a title of --repo evil/x is one --title= argument and reads back", () => { + const r = post(["pr-title", "7", "--title-file", write("title.txt", "--repo evil/x\n")]); + expect(r.status).toBe(0); + expect(calls(r.log, "gh")).toEqual([{ args: ["pr", "edit", "7", "--title=--repo evil/x"], stdin: "" }]); + }); + + test("pr-create prints the new URL", () => { + const r = post(["pr-create", "--base", "main", "--draft", "--title-file", write("t2.txt", "v1.0.0 feat: x"), "--body-file", write("b2.md", "body")]); + expect(r.status).toBe(0); + expect(r.stdout).toContain("https://github.com/acme/widget/pull/77"); + expect(calls(r.log, "gh")[0]!.args).toEqual(["pr", "create", "--base=main", "--title=v1.0.0 feat: x", "--body-file=-", "--draft"]); + }); + + test("HIGH is refused and MEDIUM waits for its token, on a public repo", () => { + const high = post(["pr-comment", "1", "--body-file", write("h.md", "key AKIA" + "1234567890ABCDEF\n")], { STUB_VISIBILITY: "PUBLIC" }); + expect(high.status).toBe(1); + expect(calls(high.log, "gh")).toEqual([]); + const file = write("m.md", "cc jane.roe@acme-corp.io\n"); + const medium = post(["pr-comment", "1", "--body-file", file], { STUB_VISIBILITY: "PUBLIC" }); + expect(medium.status).toBe(2); + expect(medium.stdout).toContain("REPO_VISIBILITY: public"); + expect(medium.stdout).toContain("RULE: pii.email LINE: 1 PART: body"); + expect(calls(medium.log, "gh")).toEqual([]); + const t = /^TOKEN: (\S+)$/m.exec(medium.stdout)![1]!; + fs.writeFileSync(file, "cc john.doe@acme-corp.io\n"); + const changed = post(["pr-comment", "1", "--body-file", file, "--confirm", t], { STUB_VISIBILITY: "PUBLIC" }); + expect(changed.status).toBe(2); + expect(calls(changed.log, "gh")).toEqual([]); + const t2 = /^TOKEN: (\S+)$/m.exec(changed.stdout)![1]!; + const sent = post(["pr-comment", "1", "--body-file", file, "--confirm", t2], { STUB_VISIBILITY: "PUBLIC" }); + expect(sent.status).toBe(0); + expect(calls(sent.log, "gh")).toEqual([{ args: ["pr", "comment", "1", "--body-file=-"], stdin: "cc john.doe@acme-corp.io\n" }]); + }); + + test("GitLab sends the body as one --description= argument", () => { + const body = "multi\nline $(id)\n"; + const r = post(["pr-body", "4", "--host", "gitlab", "--body-file", write("g.md", body)], { STUB_VISIBILITY: "" }); + expect(r.status).toBe(0); + expect(calls(r.log, "glab")).toEqual([{ args: ["mr", "update", "4", `--description=${body}`], stdin: "" }]); + }); +}); diff --git a/test/gstack-post.test.ts b/test/gstack-post.test.ts new file mode 100644 index 000000000..52b05153f --- /dev/null +++ b/test/gstack-post.test.ts @@ -0,0 +1,198 @@ +/** + * gstack-post (B4: DX-6 synopsis and exit codes, ENG-9 digest-token + * confirmation and argument safety, CEO-19 the helper owns the scan). + * + * Drives lib/gstack-post.ts in process with a fake runner, so the posting + * logic is covered on Windows too (DX-15). test/gstack-post-cli.test.ts runs + * the real executable against stub gh/glab on POSIX. + */ +import { describe, expect, test } from "bun:test"; +import { EXIT, runPost, type PostEnv } from "../lib/gstack-post"; + +interface Call { cmd: string; args: string[]; input?: string } + +function fakeEnv(opts: { + remote?: string; + files?: Record; + visibility?: string; + email?: string; + respond?: (c: Call) => { status: number; stdout?: string; stderr?: string } | undefined; +} = {}) { + const calls: Call[] = []; + const out: string[] = []; + const err: string[] = []; + let title = ""; + const env: PostEnv = { + run(cmd, args, input) { + const call = { cmd, args, input }; + calls.push(call); + const custom = opts.respond?.(call); + if (custom) return { stdout: "", stderr: "", ...custom }; + if (cmd === "git" && args[0] === "remote") return { status: 0, stdout: (opts.remote ?? "git@github.com:acme/widget.git") + "\n", stderr: "" }; + if (cmd === "git" && args[0] === "config") return { status: opts.email ? 0 : 1, stdout: (opts.email ?? "") + "\n", stderr: "" }; + if (cmd === "cfg") return { status: 0, stdout: (opts.visibility ?? "private") + "\n", stderr: "" }; + if (cmd === "gh" && args[0] === "pr" && args[1] === "edit") { + const t = args.find(a => a.startsWith("--title=")); + if (t) title = t.slice("--title=".length); + } + if (cmd === "gh" && args[0] === "pr" && args[1] === "view") return { status: 0, stdout: title + "\n", stderr: "" }; + return { status: 0, stdout: "", stderr: "" }; + }, + readFile(p) { + const f = opts.files ?? {}; + if (!(p in f)) throw new Error("ENOENT"); + return f[p]!; + }, + out: t => { out.push(t); }, + err: t => { err.push(t); }, + }; + const posts = () => calls.filter(c => (c.cmd === "gh" || c.cmd === "glab") && !(c.args[0] === "repo" || c.args[1] === "view")); + return { env, calls, posts, stdout: () => out.join(""), stderr: () => err.join("") }; +} + +const run = (argv: string[], f: ReturnType) => runPost(argv, f.env, "cfg"); +const token = (stdout: string) => /^TOKEN: ([0-9a-f]+)$/m.exec(stdout)?.[1]; +const AWS = "AKIA" + "1234567890ABCDEF"; + +describe("gstack-post posts through argv, never a shell string", () => { + test("a clean comment goes to gh as an argument array with the body on stdin", () => { + const body = "Fixed in `abc123`. $(touch pwned) \"quoted\" and 'single'\n"; + const f = fakeEnv({ files: { "/b": body } }); + expect(run(["pr-comment", "12", "--body-file", "/b"], f)).toBe(EXIT.posted); + expect(f.posts()).toEqual([{ cmd: "gh", args: ["pr", "comment", "12", "--body-file=-"], input: body }]); + expect(f.stdout()).toContain("POSTED: pr-comment 12"); + }); + + test("a title of --repo evil/x is posted as text, in --title= form", () => { + const f = fakeEnv({ files: { "/t": "--repo evil/x\n" } }); + expect(run(["pr-title", "7", "--title-file", "/t"], f)).toBe(EXIT.posted); + expect(f.posts()[0]).toEqual({ cmd: "gh", args: ["pr", "edit", "7", "--title=--repo evil/x"], input: undefined }); + const g = fakeEnv({ files: { "/t": "--repo evil/x", "/b": "body" } }); + expect(run(["pr-create", "--base", "main", "--draft", "--title-file", "/t", "--body-file", "/b"], g)).toBe(EXIT.posted); + expect(g.posts()[0]!.args).toEqual(["pr", "create", "--base=main", "--title=--repo evil/x", "--body-file=-", "--draft"]); + }); + + test("every operation's GitHub and GitLab argv", () => { + const files = { "/t": "v1.2.3 fix: title", "/b": "line one\nline two" }; + const gh = (argv: string[]) => { const f = fakeEnv({ files }); expect(run(argv, f)).toBe(0); return f.posts()[0]!; }; + expect(gh(["issue-comment", "3", "--body-file", "/b"]).args).toEqual(["issue", "comment", "3", "--body-file=-"]); + expect(gh(["reply", "4", "--to", "991", "--body-file", "/b"]).args).toEqual(["api", "--method=POST", "repos/{owner}/{repo}/pulls/4/comments/991/replies", "--field=body=@-"]); + expect(gh(["pr-body", "5", "--body-file", "/b"])).toEqual({ cmd: "gh", args: ["pr", "edit", "5", "--body-file=-"], input: files["/b"] }); + expect(gh(["issue-create", "--title-file", "/t", "--body-file", "/b"]).args).toEqual(["issue", "create", "--title=v1.2.3 fix: title", "--body-file=-"]); + const gl = (argv: string[]) => { const f = fakeEnv({ files, remote: "https://gitlab.com/grp/sub/proj.git" }); expect(run(argv, f)).toBe(0); return f.posts()[0]!; }; + expect(gl(["pr-comment", "8", "--body-file", "/b"]).args).toEqual(["mr", "note", "8", "--message=line one\nline two"]); + expect(gl(["pr-body", "8", "--body-file", "/b"]).args).toEqual(["mr", "update", "8", "--description=line one\nline two"]); + expect(gl(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"]).args) + .toEqual(["mr", "create", "--target-branch=main", "--title=v1.2.3 fix: title", "--description=line one\nline two", "--yes"]); + expect(gl(["issue-create", "--title-file", "/t", "--body-file", "/b"]).args).toEqual(["issue", "create", "--title=v1.2.3 fix: title", "--description=line one\nline two", "--yes"]); + }); + + test("a target is a number or a URL of this repository on the same host", () => { + const files = { "/b": "hello" }; + const ok = fakeEnv({ files }); + expect(run(["pr-comment", "https://github.com/Acme/widget/pull/42", "--body-file", "/b"], ok)).toBe(0); + expect(ok.posts()[0]!.args[2]).toBe("42"); + for (const bad of ["https://evil.example/acme/widget/pull/42", "https://github.com/evil/x/pull/42", "https://github.com/acme/widget/issues/42", "--repo=evil/x", "12abc"]) { + const f = fakeEnv({ files }); + expect(run(["pr-comment", bad, "--body-file", "/b"], f)).toBe(EXIT.usage); + expect(f.posts()).toEqual([]); + } + }); + + test("usage errors exit 64 and post nothing", () => { + for (const argv of [[], ["pr-merge", "1"], ["pr-title", "1", "--body-file", "/b"], ["reply", "1", "--body-file", "/b"], ["pr-comment", "1", "--body-file", "/missing"], ["pr-title", "1", "--title-file", "/two"]]) { + const f = fakeEnv({ files: { "/b": "x", "/two": "a\nb" } }); + expect(run(argv, f)).toBe(EXIT.usage); + expect(f.posts()).toEqual([]); + } + }); +}); + +describe("gstack-post owns the redaction scan", () => { + test("a HIGH secret is refused, even with --confirm, and nothing is posted", () => { + const f = fakeEnv({ files: { "/b": `see key ${AWS}\n` } }); + expect(run(["pr-comment", "1", "--body-file", "/b"], f)).toBe(EXIT.refused); + expect(f.stdout()).toContain("HIGH RULE: aws.access_key LINE: 1 PART: body"); + expect(f.stdout()).not.toContain(AWS); + expect(f.stderr()).toContain("aws.access_key"); + const g = fakeEnv({ files: { "/b": `see key ${AWS}\n` } }); + expect(run(["pr-comment", "1", "--body-file", "/b", "--confirm", "0".repeat(24)], g)).toBe(EXIT.refused); + expect([...f.posts(), ...g.posts()]).toEqual([]); + }); + + test("MEDIUM posts nothing until the caller confirms with the token for those bytes", () => { + const files = { "/b": "Ping jane.roe@acme-corp.io about it.\n" }; + const first = fakeEnv({ files, visibility: "public" }); + expect(run(["issue-comment", "9", "--body-file", "/b"], first)).toBe(EXIT.confirm); + expect(first.stdout()).toContain("REPO_VISIBILITY: public"); + expect(first.stdout()).toContain("RULE: pii.email LINE: 1 PART: body"); + expect(first.stdout()).not.toContain("jane.roe"); + expect(first.posts()).toEqual([]); + const t = token(first.stdout())!; + expect(t).toMatch(/^[0-9a-f]{24}$/); + const second = fakeEnv({ files, visibility: "public" }); + expect(run(["issue-comment", "9", "--body-file", "/b", "--confirm", t], second)).toBe(EXIT.posted); + expect(second.posts()).toEqual([{ cmd: "gh", args: ["issue", "comment", "9", "--body-file=-"], input: files["/b"] }]); + }); + + test("changed bytes with the same rule and line need a new token", () => { + const a = fakeEnv({ files: { "/b": "Ping jane.roe@acme-corp.io about it.\n" } }); + expect(run(["pr-comment", "9", "--body-file", "/b"], a)).toBe(EXIT.confirm); + const old = token(a.stdout())!; + const b = fakeEnv({ files: { "/b": "Ping john.doe@acme-corp.io about it.\n" } }); + expect(run(["pr-comment", "9", "--body-file", "/b", "--confirm", old], b)).toBe(EXIT.confirm); + expect(b.stdout()).toContain("RULE: pii.email LINE: 1 PART: body"); + expect(token(b.stdout())).not.toBe(old); + expect(b.stderr()).toContain("changed since that token"); + expect(b.posts()).toEqual([]); + }); + + test("the token is bound to the destination and operation", () => { + const files = { "/b": "Ping jane.roe@acme-corp.io about it.\n" }; + const a = fakeEnv({ files }); + run(["pr-comment", "9", "--body-file", "/b"], a); + const t = token(a.stdout())!; + for (const argv of [["pr-comment", "10", "--body-file", "/b"], ["issue-comment", "9", "--body-file", "/b"], ["pr-body", "9", "--body-file", "/b"]]) { + const f = fakeEnv({ files }); + expect(run([...argv, "--confirm", t], f)).toBe(EXIT.confirm); + expect(f.posts()).toEqual([]); + } + }); + + test("the title is scanned too, and the pusher's own email is not a finding", () => { + const f = fakeEnv({ files: { "/t": "v1 fix for jane.roe@acme-corp.io", "/b": "body" } }); + expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], f)).toBe(EXIT.confirm); + expect(f.stdout()).toContain("RULE: pii.email LINE: 1 PART: title"); + const own = fakeEnv({ files: { "/b": "Ping jane.roe@acme-corp.io" }, email: "jane.roe@acme-corp.io" }); + expect(run(["pr-comment", "1", "--body-file", "/b"], own)).toBe(EXIT.posted); + }); +}); + +describe("gstack-post reports CLI failures and repairs known edit failures", () => { + test("a failed gh call exits 3", () => { + const f = fakeEnv({ files: { "/b": "hi" }, respond: c => (c.cmd === "gh" && c.args[0] === "pr" ? { status: 1, stderr: "HTTP 404" } : undefined) }); + expect(run(["pr-comment", "1", "--body-file", "/b"], f)).toBe(EXIT.cliFailed); + expect(f.stderr()).toContain("HTTP 404"); + }); + + test("the projectCards GraphQL failure falls back to the REST PATCH with the same bytes", () => { + const f = fakeEnv({ files: { "/b": "new body" }, respond: c => (c.args[1] === "edit" ? { status: 1, stderr: "GraphQL: repository.pullRequest.projectCards is deprecated" } : undefined) }); + expect(run(["pr-body", "6", "--body-file", "/b"], f)).toBe(EXIT.posted); + expect(f.posts().at(-1)).toEqual({ cmd: "gh", args: ["api", "--method=PATCH", "repos/{owner}/{repo}/pulls/6", "--field=body=@-", "--silent"], input: "new body" }); + }); + + test("a title that does not read back is retried once, then reported", () => { + let views = 0; + const f = fakeEnv({ files: { "/t": "v2 feat: x" }, respond: c => (c.args[1] === "view" ? { status: 0, stdout: views++ === 0 ? "old\n" : "v2 feat: x\n" } : undefined) }); + expect(run(["pr-title", "2", "--title-file", "/t"], f)).toBe(EXIT.posted); + expect(f.posts().filter(c => c.args[1] === "edit")).toHaveLength(2); + const stuck = fakeEnv({ files: { "/t": "v2 feat: x" }, respond: c => (c.args[1] === "view" ? { status: 0, stdout: "old\n" } : undefined) }); + expect(run(["pr-title", "2", "--title-file", "/t"], stuck)).toBe(EXIT.cliFailed); + }); + + test("an unrecognized remote host asks gh for the repository URL", () => { + const f = fakeEnv({ files: { "/b": "hi" }, remote: "https://proxy.internal/acme/widget.git", respond: c => (c.args[0] === "repo" && c.cmd === "gh" ? { status: 0, stdout: "https://github.com/acme/widget\n" } : undefined) }); + expect(run(["pr-comment", "https://github.com/acme/widget/pull/3", "--body-file", "/b"], f)).toBe(EXIT.posted); + expect(f.posts()[0]!.args.slice(0, 3)).toEqual(["pr", "comment", "3"]); + }); +});