pi-graphite 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,9 @@ dependent branches.
79
79
  - Editor / pager / browser env is forced safe (`GT_EDITOR=true`, `GT_PAGER=`,
80
80
  `BROWSER=true`, …). Commands have a hard timeout.
81
81
  - Interactive editor / hunk / browser / reorder paths are not exposed.
82
+ - Rendered `$ gt …` command lines in tool output are POSIX shell-quoted so
83
+ copy-paste cannot trigger command substitution or word-splitting from
84
+ user-controlled args.
82
85
  - `graphite_setup action=track_branch` requires explicit `branch`, explicit
83
86
  `parent`, and `confirmParent:true`; do not guess parent if unclear.
84
87
  - `graphite_setup action=init_repo reset:true` needs `confirmDestructive:true`.
@@ -94,6 +97,14 @@ dependent branches.
94
97
  `branchNotTracked`, `noChangesStaged`, `checkedOutElsewhere`,
95
98
  `operatingOnTrunk`, …).
96
99
 
100
+ ### Known surface: git hooks
101
+
102
+ This extension does not pass `--no-verify` to `gt` / `git`. Any
103
+ `pre-commit`, `commit-msg`, `pre-push`, or related hook configured in the
104
+ target repo will execute as part of mutating operations (create, amend,
105
+ submit, …). Hooks are arbitrary user code and are intentionally not
106
+ bypassed; treat hook content as part of the repo's trust boundary.
107
+
97
108
  ## License
98
109
 
99
110
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-graphite",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Opinionated pi tools + skill for stacked PR workflows with the Graphite (gt) CLI.",
5
5
  "keywords": [
6
6
  "pi",
package/src/lib/argv.ts CHANGED
@@ -57,3 +57,25 @@ export function flagEq(flag: string, value: string | number): string {
57
57
  }
58
58
  return `${flag}=${value}`;
59
59
  }
60
+
61
+ /**
62
+ * POSIX shell single-quote a value so the rendered command line is safe to
63
+ * copy-paste into a shell. argv execution itself never goes through a shell
64
+ * (we use spawn with an argv array), but rendered commands appear in tool
65
+ * output and labels; a user pasting them must not trigger command
66
+ * substitution, word splitting, or metacharacter interpretation.
67
+ *
68
+ * Rule: wrap in single quotes, and replace each embedded single quote with
69
+ * the POSIX-portable sequence '\''. Tokens consisting solely of
70
+ * [A-Za-z0-9_=:,.@/+-] are left unquoted for readability.
71
+ */
72
+ export function shellQuote(arg: string): string {
73
+ if (arg.length === 0) return "''";
74
+ if (/^[A-Za-z0-9_=:,.@\/+\-]+$/.test(arg)) return arg;
75
+ return `'${arg.replace(/'/g, "'\\''")}'`;
76
+ }
77
+
78
+ /** Join argv tokens into a shell-safe single line. */
79
+ export function shellJoin(args: readonly string[]): string {
80
+ return args.map(shellQuote).join(" ");
81
+ }
package/src/lib/result.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, safeNoninteractiveEnv, type GtRunResult } from "./exec";
2
+ import { shellJoin } from "./argv";
3
+ import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, runGt, safeNoninteractiveEnv, type GtRunResult } from "./exec";
3
4
 
4
5
  /**
5
6
  * Structured failure hints. Only populated when the underlying `gt` command
@@ -108,7 +109,7 @@ export function formatResult(r: GtRunResult): FormattedResult {
108
109
  export function renderText(label: string, f: FormattedResult): string {
109
110
  const r = f.result;
110
111
  const lines: string[] = [];
111
- lines.push(`$ gt ${r.args.join(" ")}`);
112
+ lines.push(`$ gt ${shellJoin(r.args)}`);
112
113
  lines.push(
113
114
  `# cwd=${r.cwd} exit=${r.exitCode}${r.timedOut ? " (aborted)" : ""}${
114
115
  r.spawnError ? ` (spawn-error: ${r.spawnError})` : ""
@@ -176,7 +177,11 @@ async function detectCurrentBranch(cwd: string): Promise<string | undefined> {
176
177
  }
177
178
 
178
179
  async function detectTrunk(cwd: string): Promise<string | undefined> {
179
- const out = await execText("gt", ["--cwd", cwd, "--no-interactive", "trunk"], cwd);
180
+ // Route through the hardened runner so cwd resolve, forbidden-token scan,
181
+ // trailing --no-interactive injection, and env scrubbing all apply.
182
+ const r = await runGt(["trunk"], { cwd }).catch(() => undefined);
183
+ if (!r || r.exitCode !== 0) return undefined;
184
+ const out = r.stdout;
180
185
  // gt trunk prints the trunk name on its own line. Take last non-empty line.
181
186
  const cleaned = out
182
187
  .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { assertSafeRef, flagEq } from "../lib/argv";
3
+ import { assertSafeRef, flagEq, shellJoin } from "../lib/argv";
4
4
  import { ensureSuccess, renderText } from "../lib/result";
5
5
  import {
6
6
  CwdParam,
@@ -128,7 +128,7 @@ export function registerChange(pi: ExtensionAPI) {
128
128
  break;
129
129
  }
130
130
  }
131
- const label = `gt ${args.join(" ")}`;
131
+ const label = `gt ${shellJoin(args)}`;
132
132
  const r = await runGt(args, { cwd: p.cwd, signal });
133
133
  const f = await ensureSuccess(label, r, p.cwd);
134
134
  return {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { assertSafeRef, flagEq } from "../lib/argv";
3
+ import { assertSafeRef, flagEq, shellJoin } from "../lib/argv";
4
4
  import { ensureSuccess, renderText } from "../lib/result";
5
5
  import {
6
6
  CwdParam,
@@ -87,7 +87,7 @@ export function registerNavigate(pi: ExtensionAPI) {
87
87
  args = ["bottom"];
88
88
  break;
89
89
  }
90
- const label = `gt ${args.join(" ")}`;
90
+ const label = `gt ${shellJoin(args)}`;
91
91
  const r = await runGt(args, { cwd: p.cwd, signal });
92
92
  const f = await ensureSuccess(label, r, p.cwd);
93
93
  return {
@@ -7,6 +7,7 @@ import {
7
7
  runGt,
8
8
  safeNoninteractiveEnv,
9
9
  } from "../lib/exec";
10
+ import { shellJoin } from "../lib/argv";
10
11
  import { ensureSuccess, renderText } from "../lib/result";
11
12
  import { CwdParam, StringEnum, Type, type ToolReturn } from "../lib/schema";
12
13
 
@@ -135,7 +136,7 @@ export function registerRecover(pi: ExtensionAPI) {
135
136
  if (p.force) args.push("--force");
136
137
  break;
137
138
  }
138
- const label = `gt ${args.join(" ")}`;
139
+ const label = `gt ${shellJoin(args)}`;
139
140
  const r = await runGt(args, { cwd: p.cwd, signal });
140
141
  const f = await ensureSuccess(label, r, p.cwd);
141
142
  return {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { assertSafeRef, flagEq } from "../lib/argv";
3
+ import { assertSafeRef, flagEq, shellJoin } from "../lib/argv";
4
4
  import { ensureSuccess, renderText } from "../lib/result";
5
5
  import {
6
6
  CwdParam,
@@ -109,7 +109,7 @@ export function registerSetup(pi: ExtensionAPI) {
109
109
  if (p.force) args.push("--force");
110
110
  }
111
111
 
112
- const label = `gt ${args.join(" ")}`;
112
+ const label = `gt ${shellJoin(args)}`;
113
113
  const r = await runGt(args, { cwd: p.cwd, signal });
114
114
  const f = await ensureSuccess(label, r, p.cwd);
115
115
  return {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runGt } from "../lib/exec";
3
- import { assertSafeRef, flagEq } from "../lib/argv";
3
+ import { assertSafeRef, flagEq, shellJoin } from "../lib/argv";
4
4
  import { ensureSuccess, renderText } from "../lib/result";
5
5
  import {
6
6
  CwdParam,
@@ -104,18 +104,30 @@ export function registerSubmitStack(pi: ExtensionAPI) {
104
104
  if (p.mergeWhenReady) args.push("--merge-when-ready");
105
105
  if (p.rerequestReview) args.push("--rerequest-review");
106
106
 
107
+ const assertReviewer = (rv: string, label: string) => {
108
+ assertSafeRef(rv, label);
109
+ // gt joins reviewers on ',' before calling gh. Reject any element
110
+ // that itself contains a comma or whitespace so a single array
111
+ // entry cannot expand into multiple reviewers.
112
+ if (/[,\s]/.test(rv)) {
113
+ throw new Error(
114
+ `${label} must not contain commas or whitespace (got ${JSON.stringify(rv)}). ` +
115
+ `Pass each reviewer as a separate array element.`,
116
+ );
117
+ }
118
+ };
107
119
  if (p.reviewers && p.reviewers.length) {
108
- for (const rv of p.reviewers) assertSafeRef(rv, "reviewers[]");
120
+ for (const rv of p.reviewers) assertReviewer(rv, "reviewers[]");
109
121
  args.push(flagEq("--reviewers", p.reviewers.join(",")));
110
122
  }
111
123
  if (p.teamReviewers && p.teamReviewers.length) {
112
- for (const rv of p.teamReviewers) assertSafeRef(rv, "teamReviewers[]");
124
+ for (const rv of p.teamReviewers) assertReviewer(rv, "teamReviewers[]");
113
125
  args.push(flagEq("--team-reviewers", p.teamReviewers.join(",")));
114
126
  }
115
127
  if (p.forcePush) args.push("--force");
116
128
  if (p.ignoreOutOfSyncTrunk) args.push("--ignore-out-of-sync-trunk");
117
129
 
118
- const label = `gt ${args.join(" ")}`;
130
+ const label = `gt ${shellJoin(args)}`;
119
131
  const r = await runGt(args, { cwd: p.cwd, signal });
120
132
  const f = await ensureSuccess(label, r, p.cwd);
121
133
  return {
package/src/tools/sync.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { shellJoin } from "../lib/argv";
2
3
  import { runGt } from "../lib/exec";
3
4
  import { ensureSuccess, renderText } from "../lib/result";
4
5
  import {
@@ -67,7 +68,7 @@ export function registerSync(pi: ExtensionAPI) {
67
68
  if (p.force) args.push("--force");
68
69
  if (p.restack === false) args.push("--no-restack");
69
70
 
70
- const label = `gt ${args.join(" ")}`;
71
+ const label = `gt ${shellJoin(args)}`;
71
72
  const r = await runGt(args, { cwd: p.cwd, signal });
72
73
  const f = await ensureSuccess(label, r, p.cwd);
73
74
  return {