pi-orchestrator-config 4.22.1 → 4.22.3

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
@@ -477,23 +477,6 @@ entries) is what pi carries between sessions. Trail's own layer —
477
477
  `.trail/learnings/`, `trail note`, the pre-ship `trail check` — is upstream's and
478
478
  **we do not use it**. We only read the code map.
479
479
 
480
- ### Migrating from Graft
481
-
482
- The integration was renamed from Graft to Trail-code. The break is intentional:
483
-
484
- - Only `@trailhq/trail-code` is installed and only `trail` is invoked. The `graft`
485
- binary is no longer installed and there is no fallback — if you only have
486
- `@nanonets/graft`, install trail to get the map tools back.
487
- - `graft_enable` / `PI_GRAFT_ENABLE` are gone, with no alias and no migration. An
488
- existing `graft_enable: true` is a dead key, which is harmless: the feature is
489
- default-on when the binary exists.
490
- - Tools are `trail_find_code`, `trail_find_all`, `trail_file_api`,
491
- `trail_trace_calls`, `trail_repo_map`, `trail_refresh`, `trail_blast_radius`.
492
- - `log_graft` becomes `log_trail` (env `PI_LOG_TRAIL`); the old `~/.pi/logs/graft`
493
- directory is neither read nor migrated.
494
- - `graft/` survives as a _path_ until upstream renames it: it is the map
495
- directory, not the product.
496
-
497
480
  ### Cache Miss Notices
498
481
 
499
482
  Enable `showCacheMissNotices` in pi settings to see transcript notices on significant prompt-cache misses — useful for investigating unexpected token costs:
@@ -6,13 +6,14 @@
6
6
  import { createLogger } from "../shared/logger.js";
7
7
  import { createHash } from "node:crypto";
8
8
  import { lstatSync, realpathSync, mkdirSync, rmdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
9
10
  import { execFileSync } from "node:child_process";
10
11
  import * as path from "node:path";
11
12
  import { join } from "node:path";
12
13
  import { getProjectDataDir } from "./utils.js";
13
14
  import { getGlobalSettingsPath, getSettingsPath, loadGlobalSettings, loadProjectSettings } from "./project-settings.js";
14
15
  import { readSettingsFile, writeSettingsFile } from "./settings-tui-helpers.js";
15
- import { DANGEROUS, executableText, getCurrentBranch, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
16
+ import { DANGEROUS, WRAPPER_VALUE_OPTIONS, executableText, getCurrentBranch, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
16
17
 
17
18
  const enfLog = createLogger("enforcement");
18
19
  enfLog.debug("enforcement-helpers module loaded");
@@ -313,7 +314,7 @@ function gitCsIn(segment: string): string[] {
313
314
  }
314
315
 
315
316
  /** Words that run whatever follows them, so an assignment behind one still counts. */
316
- const WRAPPER_WORDS = new Set(["sudo", "command", "exec", "time", "nohup", "nice", "stdbuf", "setsid", "timeout"]);
317
+ const WRAPPER_WORDS = new Set(["sudo", "command", "exec", "time", "nohup", "nice", "stdbuf", "setsid", "timeout", "rtk"]);
317
318
 
318
319
  /** env options whose value is a separate word. */
319
320
  const ENV_VALUE_OPTIONS = new Set([
@@ -324,6 +325,26 @@ const ENV_VALUE_OPTIONS = new Set([
324
325
  const GIT_INDEX_VARS = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]);
325
326
  const GIT_INDEX_OPTIONS = new Set(["--git-dir", "--work-tree", "--namespace"]);
326
327
 
328
+ /** Command words that hand a quoted string to a shell, which then really runs it. */
329
+ const SHELL_WORDS = new Set(["bash", "sh", "zsh", "dash", "ksh", "eval"]);
330
+
331
+ /** True when this segment RUNS a shell (behind any wrapper/assignment/env prefix).
332
+ * A quoted argument containing shell-looking text is not a shell run: only the
333
+ * command word counts. */
334
+ function runsNestedShell(tokens: string[]): boolean {
335
+ for (let i = 0; i < tokens.length; i++) {
336
+ const tok = tokens[i];
337
+ if (WRAPPER_WORDS.has(tok) || tok === "env" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
338
+ if (tok.startsWith("-")) {
339
+ // env's own options sit between `env` and the command it runs.
340
+ if (!tok.includes("=") && ENV_VALUE_OPTIONS.has(tok.split("=")[0])) i++;
341
+ continue;
342
+ }
343
+ return SHELL_WORDS.has(tok.replace(/^[()]+/, "").replace(/.*\//, ""));
344
+ }
345
+ return false;
346
+ }
347
+
327
348
  /**
328
349
  * Anything that points git at a repository other than the working directory: the
329
350
  * GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE environment variables, or the equivalent
@@ -336,21 +357,47 @@ const GIT_INDEX_OPTIONS = new Set(["--git-dir", "--work-tree", "--namespace"]);
336
357
  * only count in git's global option region, before the subcommand, so
337
358
  * `git add -- --work-tree=x` is a filename too.
338
359
  */
339
- function gitIndexOverride(command: string): string | null {
360
+ /** What a scan looks for: the environment variables, git global options, and
361
+ * `-c` config keys that select the repository or identity git runs with. */
362
+ interface GitOverrideTargets {
363
+ /** Variables that count in assignment position. */
364
+ env: Set<string>;
365
+ /** Long options in git's global region, before the subcommand. */
366
+ flags: Set<string>;
367
+ /** Keys of `-c <key>=<value>` / `--config-env=<key>=<VAR>`. */
368
+ configKeys: Set<string>;
369
+ }
370
+
371
+ const NO_KEYS: Set<string> = new Set();
372
+
373
+ /**
374
+ * The one assignment/option walk behind both git override guards
375
+ * (gitIndexOverride and gitIdentityOverride). Returns a label for what it found,
376
+ * or null.
377
+ *
378
+ * Position matters. An environment variable only counts in *assignment
379
+ * position* — `GIT_DIR=/x git add a.txt`, optionally through `env` — so
380
+ * `git add GIT_INDEX_FILE=notes` stages a file with that name. Options only
381
+ * count in git's global region before the subcommand, so
382
+ * `git add -- --work-tree=x` is a filename too.
383
+ */
384
+ function scanGitOverrides(command: string, targets: GitOverrideTargets): string | null {
340
385
  const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/;
386
+ const nameOf = (tok: string) => tok.split("=")[0];
341
387
  for (const seg of unquotedSegments(command)) {
342
388
  const tokens = tokenize(seg.text);
343
- const nameOf = (tok: string) => tok.split("=")[0];
344
389
  let i = 0;
345
390
  // `export GIT_INDEX_FILE=/x` sets it for later commands in the same shell,
346
391
  // so it points git at another index just as an inline assignment does. This
347
392
  // segment does not run git itself, so it is checked before that requirement.
348
- if (tokens[i] === "export") {
393
+ // The executable view of a nested shell wraps a segment in `(`, so `(export`
394
+ // is the same word.
395
+ if (tokens[i]?.replace(/^\(+/, "") === "export") {
349
396
  i++;
350
397
  while (i < tokens.length) {
351
398
  const name = nameOf(tokens[i]);
352
- if (GIT_INDEX_VARS.has(name)) {
353
- enfLog.warn("git_index_override", "source", name, "via", "export");
399
+ if (targets.env.has(name)) {
400
+ enfLog.warn("git_override", "source", name, "via", "export");
354
401
  return name;
355
402
  }
356
403
  i++;
@@ -358,17 +405,30 @@ function gitIndexOverride(command: string): string | null {
358
405
  continue;
359
406
  }
360
407
  // `echo GIT_INDEX_FILE=/x` only prints the name. An override is something
361
- // git is actually run with, so the segment has to run git for it to count.
362
- if (!segmentRunsGit(seg.text)) continue;
408
+ // git is actually run with, so the segment has to run git for it to count —
409
+ // directly, or through a nested shell that this segment RUNS
410
+ // (`GIT_AUTHOR_EMAIL=x bash -c 'git commit'`), whose assignment lives here
411
+ // but whose git runs inside. A quoted argument merely MENTIONING a command
412
+ // (`gh pr create --body "…git -c user.email=x commit…"`) is prose: the
413
+ // segment runs `gh`, not a shell, so it is not followed.
414
+ if (!segmentRunsGit(seg.text) && !(runsNestedShell(tokens) && segmentRunsGit(executableText(seg.text)))) continue;
363
415
  // Leading `VAR=value` assignments, then the same again after `env`. A
364
416
  // wrapper that runs the rest — `sudo`, `command`, `exec`, `time` — sits in
365
417
  // front of both, and can be chained.
366
418
  for (let round = 0; round < 4; round++) {
367
- while (i < tokens.length && WRAPPER_WORDS.has(tokens[i])) i++;
419
+ // A wrapper runs the rest, and its OWN options sit in between: in
420
+ // `rtk -v GIT_AUTHOR_EMAIL=x git commit` the assignment follows rtk's
421
+ // option, so stepping only over the wrapper word hid it (Qodo, PR #996).
422
+ while (i < tokens.length && WRAPPER_WORDS.has(tokens[i])) {
423
+ i++;
424
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
425
+ i += WRAPPER_VALUE_OPTIONS.has(tokens[i]) ? 2 : 1;
426
+ }
427
+ }
368
428
  while (i < tokens.length && assignment.test(tokens[i])) {
369
429
  const name = nameOf(tokens[i]);
370
- if (GIT_INDEX_VARS.has(name)) {
371
- enfLog.warn("git_index_override", "source", name);
430
+ if (targets.env.has(name)) {
431
+ enfLog.warn("git_override", "source", name);
372
432
  return name;
373
433
  }
374
434
  i++;
@@ -384,14 +444,30 @@ function gitIndexOverride(command: string): string | null {
384
444
  i++;
385
445
  }
386
446
  }
387
- const g = tokens.indexOf("git");
447
+ // A segment can start mid-subshell, so `(git` is the git word too.
448
+ const g = tokens.findIndex((t) => t.replace(/^[()]+|[()]+$/g, "") === "git");
388
449
  if (g === -1) continue;
389
450
  for (let k = g + 1; k < tokens.length; k++) {
390
451
  const tok = tokens[k];
391
452
  if (!tok.startsWith("-") || tok === "-") break;
453
+ // `-c <key>=<value>`, `-c<key>=<value>`, `--config-env=<key>=<VAR>` apply to
454
+ // this one command only — no config file is written, the effect is the same.
455
+ let setting: string | undefined;
456
+ if (tok === "-c") setting = tokens[++k];
457
+ else if (tok.startsWith("-c")) setting = tok.slice(2);
458
+ else if (tok.startsWith("--config-env=")) setting = tok.slice("--config-env=".length);
459
+ if (setting !== undefined) {
460
+ const key = (setting.split("=")[0] ?? "").toLowerCase();
461
+ if (targets.configKeys.has(key)) {
462
+ const label = tok.startsWith("--config-env") ? `--config-env ${key}` : `-c ${key}`;
463
+ enfLog.warn("git_override", "source", label);
464
+ return label;
465
+ }
466
+ continue;
467
+ }
392
468
  const name = nameOf(tok);
393
- if (GIT_INDEX_OPTIONS.has(name)) {
394
- enfLog.warn("git_index_override", "source", name);
469
+ if (targets.flags.has(name)) {
470
+ enfLog.warn("git_override", "source", name);
395
471
  return name;
396
472
  }
397
473
  if (!tok.includes("=") && tokens[k + 1] && !tokens[k + 1].startsWith("-")) k++;
@@ -400,6 +476,168 @@ function gitIndexOverride(command: string): string | null {
400
476
  return null;
401
477
  }
402
478
 
479
+ function gitIndexOverride(command: string): string | null {
480
+ const found = scanGitOverrides(command, { env: GIT_INDEX_VARS, flags: GIT_INDEX_OPTIONS, configKeys: NO_KEYS });
481
+ if (found) enfLog.warn("git_index_override", "source", found);
482
+ return found;
483
+ }
484
+
485
+ /** Environment variables that set who a commit is recorded as. */
486
+ const GIT_IDENTITY_VARS = new Set([
487
+ "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
488
+ ]);
489
+
490
+ /** Git config keys that set who a commit is recorded as. */
491
+ const GIT_IDENTITY_KEYS = new Set([
492
+ "user.name", "user.email", "author.name", "author.email", "committer.name", "committer.email",
493
+ ]);
494
+
495
+ /** `git config` flags that only READ: `git config --get user.email` changes nothing. */
496
+ const CONFIG_READ_FLAGS = new Set(["--get", "--get-all", "--get-regexp", "--list", "-l"]);
497
+
498
+ /** `git config` flags that remove a key: no value follows, yet the identity moves. */
499
+ const CONFIG_UNSET_FLAGS = new Set(["--unset", "--unset-all"]);
500
+
501
+ /**
502
+ * True when a bash command WRITES into a path matching `targetSrc` (a regex
503
+ * source): a redirect (`>`, `>>`, `>|`) or `tee`/`sponge` taking it as an
504
+ * argument. Deliberately NARROW (user decision on issue #949 R3): broad
505
+ * write-verb co-occurrence taxed legitimate work and any text guard is
506
+ * bypassable anyway — this catches the lazy one-liner.
507
+ *
508
+ * Redirect STRUCTURE is read from the prose-masked command; quoted targets are
509
+ * invisible once masked, so those are matched on the raw command (Qodo #950 R5).
510
+ */
511
+ export function bashWritesToPath(command: string, maskedCommand: string, targetSrc: string): boolean {
512
+ // `tee -a <path>` / `tee --append <path>`: tee's own options sit between it and
513
+ // the path it writes.
514
+ const STRUCTURAL = String.raw`>+\|?\s*\S*${targetSrc}|(?:^|[\s;&|(])(?:tee|sponge)(?:\s+-[A-Za-z-]+)*\s+\S*${targetSrc}`;
515
+ const QUOTED = String.raw`>+\s*["'][^"']*${targetSrc}|(?:^|[\s;&|(])(?:tee|sponge)(?:\s+-[A-Za-z-]+)*\s+["'][^"']*${targetSrc}`;
516
+ let result = new RegExp(STRUCTURAL, "i").test(maskedCommand) || new RegExp(QUOTED, "i").test(command);
517
+ if (!result) {
518
+ // A nested shell's script is code, not prose, so a redirect inside it
519
+ // (`bash -c 'echo x >> .git/config'`) still writes the file. Only segments
520
+ // that RUN a shell are read this way — a quoted argument merely describing
521
+ // a command stays prose (Qodo, PR #996).
522
+ for (const seg of unquotedSegments(command)) {
523
+ if (!runsNestedShell(tokenize(seg.text))) continue;
524
+ if (new RegExp(STRUCTURAL, "i").test(executableText(seg.text))) {
525
+ result = true;
526
+ break;
527
+ }
528
+ }
529
+ }
530
+ enfLog.debug("bash_writes_to_path", targetSrc, result);
531
+ return result;
532
+ }
533
+
534
+
535
+ const IDENTITY_TARGETS: GitOverrideTargets = {
536
+ env: GIT_IDENTITY_VARS,
537
+ flags: NO_KEYS,
538
+ configKeys: GIT_IDENTITY_KEYS,
539
+ };
540
+
541
+ /**
542
+ * Anything that sets the identity a commit is recorded under (issue #995): the
543
+ * `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables in assignment
544
+ * position, a
545
+ * per-command `git -c user.name=...`, a `git config` WRITE of an identity key,
546
+ * or `git commit --author`. The identity belongs to the human — an agent that
547
+ * can change it forges commits in someone else's name.
548
+ *
549
+ * Returns a label of what was found, or null.
550
+ *
551
+ * shortcut: a `-c` key held in a shell variable (`-c "$VAR=x"`) does not match,
552
+ * and a quoted value nested two shells deep is seen only if the outer view keeps
553
+ * it parseable — upgrade if either shows up in the wild.
554
+ */
555
+ export function gitIdentityOverride(command: string): string | null {
556
+ // A nested shell string (`bash -c '...'`, `eval`) really runs, so both views
557
+ // are checked: the raw text keeps quoted option VALUES parseable
558
+ // (`-c "user.name=x"`), the executable view exposes what a nested shell runs.
559
+ const executable = executableText(command);
560
+ for (const text of executable && executable !== command ? [command, executable] : [command]) {
561
+ const found = scanGitOverrides(text, IDENTITY_TARGETS) ?? identityWritesIn(text);
562
+ if (found) {
563
+ enfLog.warn("git_identity_override", "source", found);
564
+ return found;
565
+ }
566
+ }
567
+ enfLog.debug("git_identity_override", "none", command.length);
568
+ return null;
569
+ }
570
+
571
+ /**
572
+ * The two identity writes the prefix scan cannot see: `git config <identity key>
573
+ * <value>` (written to disk for every later commit) and `git commit --author`
574
+ * (this one commit recorded as somebody else).
575
+ */
576
+ function identityWritesIn(command: string): string | null {
577
+ // `git config user.email x` — the identity written to disk for every later commit.
578
+ for (const region of gitSubArgRegions(command, "config")) {
579
+ // Tokens come from the RAW region: tokenize() drops the quotes but keeps a
580
+ // quoted value as one token, so `git config user.email "a@b"` and
581
+ // `git config "user.email" a@b` are both seen, while a value that merely
582
+ // MENTIONS the key stays a single token and is never a key (Qodo, PR #996).
583
+ const tokens = tokenize(region);
584
+ if (tokens.some((t) => CONFIG_READ_FLAGS.has(t))) continue;
585
+ const at = tokens.findIndex((t) => GIT_IDENTITY_KEYS.has(t.toLowerCase()));
586
+ if (at === -1) continue;
587
+ // A key alone is a READ (`git config user.email` prints it): only a value
588
+ // after it, or an unset, changes the identity.
589
+ const writes = tokens.some((t) => CONFIG_UNSET_FLAGS.has(t))
590
+ || tokens.slice(at + 1).some((t) => !t.startsWith("-"));
591
+ if (writes) return `git config ${tokens[at].toLowerCase()}`;
592
+ }
593
+ // `git commit --author` — this one commit is recorded as somebody else.
594
+ for (const region of gitSubArgRegions(command, "commit")) {
595
+ // Raw region again: `--author 'A <a@b>'` and `"--author" x` are options the
596
+ // shell passes to git, while a `-m` message mentioning --author is ONE token.
597
+ const tokens = tokenize(region);
598
+ const at = tokens.findIndex((t) => t === "--author" || t.startsWith("--author="));
599
+ if (at === -1) continue;
600
+ // A bare `--author` with no value after it sets nothing.
601
+ if (tokens[at].includes("=") || (tokens[at + 1] !== undefined && !tokens[at + 1].startsWith("-"))) {
602
+ return "--author";
603
+ }
604
+ }
605
+ return null;
606
+ }
607
+
608
+ /**
609
+ * A path that IS git's identity configuration: `~/.gitconfig`, the XDG
610
+ * `~/.config/git/config`, or a repository's `.git/config`. Writing one of these
611
+ * changes the identity every later commit is recorded under, so agents are kept
612
+ * away from them entirely.
613
+ * The two home files count only under THIS user's home — a `.gitconfig` in
614
+ * /tmp or a docs fixture is not the identity git reads (Qodo, PR #996).
615
+ */
616
+ /**
617
+ * Regex source matching a git identity config path as WRITTEN in a shell
618
+ * command — `$HOME`/`~`/absolute home forms and repo-local `.git/config`.
619
+ * One source of truth for the tripwire and its tests (Qodo, PR #996).
620
+ */
621
+ export function gitIdentityConfigSrc(): string {
622
+ const homeSrc = homedir().replace(/\\/g, "/").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
623
+ return String.raw`(?:(?:~|\$HOME|\$\{HOME\}|${homeSrc})/(?:\.gitconfig|\.config/git/config)|\.git/config)(?![A-Za-z0-9._-])`;
624
+ }
625
+
626
+ export function isGitIdentityConfigPath(p: string): boolean {
627
+ const norm = p.replace(/\\/g, "/").replace(/\/+$/, "");
628
+ const base = norm.slice(norm.lastIndexOf("/") + 1);
629
+ const dir = norm.slice(0, Math.max(norm.lastIndexOf("/"), 0));
630
+ const home = homedir().replace(/\\/g, "/").replace(/\/+$/, "");
631
+ const inHome = dir === home || dir === "~";
632
+ const result =
633
+ (inHome && base === ".gitconfig")
634
+ || norm === `${home}/.config/git/config`
635
+ || norm === "~/.config/git/config"
636
+ || /(?:^|\/)\.git\/config$/.test(norm);
637
+ enfLog.debug("is_git_identity_config_path", p, result);
638
+ return result;
639
+ }
640
+
403
641
  /**
404
642
  * Every directory the command could plausibly run git in.
405
643
  *
@@ -2039,8 +2277,13 @@ function subArgSpans(command: string, sub: string): Array<{ start: number; end:
2039
2277
  const spans: Array<{ start: number; end: number; text: string; maskedText: string; stripped: string; masked: string }> = [];
2040
2278
  for (const m of masked.matchAll(re)) {
2041
2279
  const start = (m.index ?? 0) + m[0].length;
2042
- const rest = stripped.slice(start);
2043
- const end = rest.search(/[;&|\n]/);
2280
+ // Boundaries are searched on the MASKED text, so a separator inside a quoted
2281
+ // argument is prose, not the end of the invocation: in
2282
+ // `git commit -m "message; more message" --author='A <a@b>'` the author
2283
+ // option belongs to this commit and must stay inside its region (Qodo, PR
2284
+ // #996). Masking is length-preserving, so the offset is valid on the
2285
+ // original string and the region content still comes from it.
2286
+ const end = masked.slice(start).search(/[;&|\n]/);
2044
2287
  const stop = end === -1 ? stripped.length : start + end;
2045
2288
  spans.push({
2046
2289
  start,
@@ -46,12 +46,16 @@ import {
46
46
  checkPythonPipBlock,
47
47
  checkRemoteExecBlock,
48
48
  checkTempFileEnforcement,
49
+ bashWritesToPath,
49
50
  gitAddPathspecs,
51
+ gitIdentityConfigSrc,
52
+ gitIdentityOverride,
50
53
  gitSubArgRegions,
51
54
  hasGitAddBulk,
52
55
  hasGitAddForce,
53
56
  injectSignoff,
54
57
  injectGhBodySignature,
58
+ isGitIdentityConfigPath,
55
59
  isReadOnlyStatement,
56
60
  isRealGitCommitOrPush,
57
61
  isRmInProjectTmp,
@@ -755,6 +759,17 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
755
759
  "\u26d4 Direct modification of pi-config-settings blocked — it carries the dangerous-command allowlist. Change settings via the /pi-config-settings command or your own editor, never agent tools.",
756
760
  };
757
761
  }
762
+ // Git identity config is off-limits to agents too (issue #995): the
763
+ // identity a commit is recorded under belongs to the human, so no agent
764
+ // writes it by hand — same alias-proof resolution as the settings guard.
765
+ if (isGitIdentityConfigPath(resolved) || isGitIdentityConfigPath(toReal(resolved))) {
766
+ return {
767
+ block: true,
768
+ reason:
769
+ "\u26d4 Git identity config is off-limits to agents. The git identity belongs to the human — " +
770
+ "if it is wrong, tell the user to fix their own git config.",
771
+ };
772
+ }
758
773
  }
759
774
  }
760
775
 
@@ -788,12 +803,7 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
788
803
  const maskedCmd = maskQuotedRegions(command);
789
804
  if (
790
805
  cmdLower.includes("pi-config-settings") &&
791
- (/>+\|?\s*\S*pi-config-settings/i.test(maskedCmd) ||
792
- /(?:^|[\s;&|(])(?:tee|sponge)\s+\S*pi-config-settings/i.test(maskedCmd) ||
793
- // Quoted redirect/tee TARGETS are invisible in the masked text —
794
- // catch them on the raw command (Qodo PR #950 R5).
795
- />+\s*["'][^"']*pi-config-settings/i.test(command) ||
796
- /(?:^|[\s;&|(])(?:tee|sponge)\s+["'][^"']*pi-config-settings/i.test(command))
806
+ bashWritesToPath(command, maskedCmd, "pi-config-settings")
797
807
  ) {
798
808
  return {
799
809
  block: true,
@@ -802,6 +812,21 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
802
812
  };
803
813
  }
804
814
 
815
+ // Block bash WRITES targeting a git identity config file (issue #995) —
816
+ // same narrow tripwire shape as the settings one above. The home files
817
+ // count only under THIS user's home: a `.gitconfig` in /tmp is not the
818
+ // identity git reads (Qodo, PR #996). One pattern, shared with the tests.
819
+ const IDENTITY_CONFIG_SRC = gitIdentityConfigSrc();
820
+ if (bashWritesToPath(command, maskedCmd, IDENTITY_CONFIG_SRC)) {
821
+ log.error("git_identity_config_write_blocked");
822
+ return {
823
+ block: true,
824
+ reason:
825
+ "\u26d4 Bash command writing a git identity config (~/.gitconfig, ~/.config/git/config, .git/config) blocked. " +
826
+ "The git identity belongs to the human — if it is wrong, tell the user to fix their own git config.",
827
+ };
828
+ }
829
+
805
830
  // Block repeated identical commands (polling-by-spam) — orchestrator only.
806
831
  // Skip on acpx: async LLM agents are unavailable; foreground polls are expected.
807
832
  const parentSupportsAsyncLlm = supportsAsyncLlm(
@@ -896,6 +921,23 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
896
921
  }
897
922
  }
898
923
 
924
+ // The git identity belongs to the human (issue #995): an agent that can set
925
+ // user.name/user.email can forge a commit in someone else's name. Checked
926
+ // before the git-expert split below so it holds for EVERY agent, and before
927
+ // any git-repo lookup so it holds outside a repo too.
928
+ const identityOverride = gitIdentityOverride(command);
929
+ if (identityOverride) {
930
+ // error, not warn: the command was refused, not recovered from.
931
+ log.error("git_identity_override_blocked", "source", identityOverride);
932
+ return {
933
+ block: true,
934
+ reason:
935
+ `\u26d4 Git identity override blocked (${identityOverride}). The git identity belongs to the human: ` +
936
+ "never set user.name/user.email, GIT_AUTHOR_*/GIT_COMMITTER_*, --author, or `git config user.*`. " +
937
+ "If the identity is wrong, tell the user to fix their own git config.",
938
+ };
939
+ }
940
+
899
941
  // Enforce git commit/push only via git-expert agent.
900
942
  // Use isRealGitCommitOrPush (heredoc-stripped + command-position aware) so that
901
943
  // file CONTENT / string args / paths that merely MENTION "git commit" do not trip
@@ -771,7 +771,7 @@ const GIT_EXECUTORS = new Set([
771
771
  ]);
772
772
 
773
773
  /** Wrapper options whose operand names something other than the command. */
774
- const WRAPPER_VALUE_OPTIONS = new Set([
774
+ export const WRAPPER_VALUE_OPTIONS = new Set([
775
775
  "-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-r", "--role",
776
776
  "-t", "--type", "-U", "--other-user", "-C", "--chdir", "--preserve-env",
777
777
  ]);
@@ -811,15 +811,36 @@ export function segmentRunsGit(segment: string): boolean {
811
811
  return verdict;
812
812
  }
813
813
 
814
- function classifySegment(segment: string): boolean {
815
- const words = tokenize(segment.trim());
814
+ /**
815
+ * The index of the word that runs the segment's command: skips control keywords,
816
+ * inline assignments, and TRANSPARENT prefixes whose own options sit before the
817
+ * command they run (`rtk -v git add`). Every consumer that needs "what does this
818
+ * segment actually run" goes through here, so a prefix is never handled in one
819
+ * parser and forgotten in another (Qodo, PR #996).
820
+ */
821
+ function skipToCommandWord(words: string[]): number {
816
822
  let k = 0;
817
823
  while (k < words.length) {
818
824
  // `function NAME { ... }` puts the name between the keyword and the body.
819
825
  if (words[k] === "function") { k += 2; continue; }
820
826
  if (SEGMENT_SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k])) { k += 1; continue; }
827
+ // rtk runs exactly the command after it, so the next word decides. It is not
828
+ // an executor: `rtk grep git add f` runs grep, it does not stage.
829
+ if (words[k] === "rtk") {
830
+ k += 1;
831
+ while (k < words.length && words[k].startsWith("-") && words[k] !== "-") {
832
+ k += WRAPPER_VALUE_OPTIONS.has(words[k]) ? 2 : 1;
833
+ }
834
+ continue;
835
+ }
821
836
  break;
822
837
  }
838
+ return k;
839
+ }
840
+
841
+ function classifySegment(segment: string): boolean {
842
+ const words = tokenize(segment.trim());
843
+ const k = skipToCommandWord(words);
823
844
  if (k < words.length) {
824
845
  // A segment can start mid-subshell, so `(git` is still the git command.
825
846
  const head = words[k].replace(/^[()]+|[()]+$/g, "");
@@ -847,13 +868,7 @@ function executedGitCommands(cmd: string): string[][] {
847
868
  const kept: string[][] = [];
848
869
  for (const raw of cmd.split(/[\n;&|()]+/)) {
849
870
  const words = tokenize(raw.trim());
850
- let k = 0;
851
- while (k < words.length) {
852
- // `function NAME { ... }` puts the name between the keyword and the body.
853
- if (words[k] === "function") { k += 2; continue; }
854
- if (SEGMENT_SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k])) { k += 1; continue; }
855
- break;
856
- }
871
+ const k = skipToCommandWord(words);
857
872
  if (k >= words.length) continue;
858
873
  // A segment can start mid-subshell, so `(git` is still the git command.
859
874
  const head = words[k].replace(/^[()]+|[()]+$/g, "");
@@ -893,10 +908,8 @@ const GLOBAL_LONG_VALUE_OPTIONS = new Set(["--git-dir", "--work-tree", "--namesp
893
908
  */
894
909
  /** The index of the word that runs git, or -1. */
895
910
  function gitWordIndex(words: string[]): number {
896
- let k = 0;
897
- while (k < words.length) {
898
- if (words[k] === "function") { k += 2; continue; }
899
- if (SEGMENT_SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k])) { k += 1; continue; }
911
+ const k = skipToCommandWord(words);
912
+ if (k < words.length) {
900
913
  const head = words[k].replace(/^[()]+|[()]+$/g, "");
901
914
  if (isGitWord(head)) return k;
902
915
  // A wrapper runs whatever follows it. `command -v git` prints the path
@@ -22,6 +22,8 @@ const MIN_PROMPT_CHARS = 12;
22
22
  const QUERY_MAX_CHARS = 2_000;
23
23
  const QUERY_FALLBACK_CHARS = 200;
24
24
  const INTERACTIVE_TIMEOUT_MS = 2_000;
25
+ // The first retrieval of a session pays process spawn plus map load (median 1.4s, #992), so cold gets a larger ceiling than warm.
26
+ const COLD_RETRIEVAL_TIMEOUT_MS = 10_000;
25
27
  export const TRAIL_QUERY_TOOLS = ["trail_find_code", "trail_find_all", "trail_file_api", "trail_trace_calls", "trail_repo_map"] as const;
26
28
  /**
27
29
  * The five read-only Trail query tools, added to a child's tool list when it declares one.
@@ -48,7 +50,7 @@ export function trailToolsArgs(tools: readonly string[] | undefined, cwd: string
48
50
  export type TrailState = "syncing" | "ready" | "stale" | "absent" | "failed";
49
51
  export type TrailRun = (args: readonly string[], options: { cwd: string; timeoutMs?: number }) => Promise<{ stdout: string; stderr: string; code: number }>;
50
52
  type GraphStatus = "fresh" | "stale" | "absent";
51
- type Options = { enabled: boolean; setting?: (cwd: string, logger?: Logger) => boolean; executable?: () => Promise<boolean>; graph?: (cwd: string) => Promise<GraphStatus> | GraphStatus; retrieve?: (query: string, cwd: string) => Promise<{ pointers: string[] }>; worktree?: (cwd: string) => string | null; run?: TrailRun; interactiveTimeoutMs?: number; logger?: Logger };
53
+ type Options = { enabled: boolean; setting?: (cwd: string, logger?: Logger) => boolean; executable?: () => Promise<boolean>; graph?: (cwd: string) => Promise<GraphStatus> | GraphStatus; retrieve?: (query: string, cwd: string) => Promise<{ pointers: string[] }>; worktree?: (cwd: string) => string | null; run?: TrailRun; interactiveTimeoutMs?: number; coldTimeoutMs?: number; logger?: Logger };
52
54
  type StatusTheme = { fg: (color: string, value: string) => string };
53
55
  type Hit = { title?: string; pointer?: string; snippet?: string; code?: string };
54
56
  type Ask = { coverage?: number; coverageStrong?: number; hits?: Hit[] };
@@ -71,11 +73,11 @@ export function trailMapDir(root: string, logger: Logger = log): string | null {
71
73
  logger.debug("map_dir_resolved", { root, layout: "trail", dir: trail });
72
74
  return trail;
73
75
  }
74
- const graft = join(root, "graft");
75
- if (existsSync(join(graft, ".graph", "wiring.json"))) {
76
+ const legacy = join(root, "graft");
77
+ if (existsSync(join(legacy, ".graph", "wiring.json"))) {
76
78
  // Upstream owns this path name; we only read it until trail renames it.
77
- logger.debug("map_dir_resolved", { root, layout: "legacy", dir: graft });
78
- return graft;
79
+ logger.debug("map_dir_resolved", { root, layout: "legacy", dir: legacy });
80
+ return legacy;
79
81
  }
80
82
  // No map yet: `.trail/cache` or `.trail/learnings` marks a repo on trail before
81
83
  // its first build lands, so that is where a map will appear.
@@ -145,7 +147,12 @@ function repoRelative(root: string, value: unknown): string | null {
145
147
  return rel.startsWith("..") || isAbsolute(rel) ? null : rel.split(sep).join("/");
146
148
  }
147
149
  function samePath(left: string, right: string): boolean { return left === right || left.endsWith(`/${right}`) || right.endsWith(`/${left}`); }
148
- function savedTokens(output: string): number { return [...output.matchAll(/\[(?:trail|graft)\] tokens saved ≈ ([\d,]+)/g)].reduce((total, match) => total + (Number(match[1].replaceAll(",", "")) || 0), 0); }
150
+ function savedTokens(output: string, logger: Logger = log): number {
151
+ const matches = [...output.matchAll(/\[trail\] tokens saved ≈ ([\d,]+)/g)];
152
+ const total = matches.reduce((sum, match) => sum + (Number(match[1].replaceAll(",", "")) || 0), 0);
153
+ logger.debug("saved_tokens", { matches: matches.length, total });
154
+ return total;
155
+ }
149
156
  function savingsStore(ctx: any, root: string, logger: Logger = log): string {
150
157
  let id: unknown = process.env.PI_SUBAGENT_CHILD === "1" ? process.env.__PI_PARENT_SESSION_ID : undefined;
151
158
  const source = id ? "parent" : "session";
@@ -401,7 +408,7 @@ export function createTrailIntegration(options: Options) {
401
408
  // telemetry into ~/.pi/logs/trail, which is the G1-G3 measurement source for #978.
402
409
  const log = options.logger ?? createLogger("trail");
403
410
  const child = process.env.PI_SUBAGENT_CHILD === "1";
404
- const state = { generation: 0, root: "", store: "", dirtySignal: "", enabled: false, toolsRegistered: false, events: undefined as ExtensionAPI["events"] | undefined, value: "stale" as TrailState, nodeCount: 0, tokenSavings: 0, trailReads: 0, sourceReads: 0, repoMapShown: false, dirty: false, buildAttempted: false, refresh: undefined as Promise<boolean> | undefined, mapPaths: new Set<string>(), mapDirs: new Set<string>(), ctx: undefined as any, gate: { active: false, retrieved: false, pointers: new Set<string>() } };
411
+ const state = { generation: 0, root: "", store: "", dirtySignal: "", enabled: false, toolsRegistered: false, events: undefined as ExtensionAPI["events"] | undefined, value: "stale" as TrailState, nodeCount: 0, tokenSavings: 0, trailReads: 0, sourceReads: 0, repoMapShown: false, dirty: false, buildAttempted: false, warmed: false, coldAttempts: 0, refresh: undefined as Promise<boolean> | undefined, mapPaths: new Set<string>(), mapDirs: new Set<string>(), ctx: undefined as any, gate: { active: false, retrieved: false, pointers: new Set<string>() } };
405
412
  const publishSavings = () => {
406
413
  if (child || !state.events) return;
407
414
  state.events.emit("pidash:trail-savings", { tokenSavings: state.tokenSavings });
@@ -498,7 +505,7 @@ export function createTrailIntegration(options: Options) {
498
505
  } catch {}
499
506
  };
500
507
  const invalidate = (ctx: any, reason: string) => {
501
- state.generation++; state.ctx = ctx; state.enabled = false; state.root = ""; state.store = ""; state.dirtySignal = ""; state.refresh = undefined; state.dirty = false; state.buildAttempted = false; state.nodeCount = 0; state.tokenSavings = 0; state.mapPaths = new Set(); state.repoMapShown = false; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
508
+ state.generation++; state.ctx = ctx; state.enabled = false; state.root = ""; state.store = ""; state.dirtySignal = ""; state.refresh = undefined; state.dirty = false; state.buildAttempted = false; state.warmed = false; state.coldAttempts = 0; state.nodeCount = 0; state.tokenSavings = 0; state.mapPaths = new Set(); state.repoMapShown = false; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
502
509
  clearSlot("trail", ctx); publishSavings(); log.info("session_invalidated", { reason });
503
510
  };
504
511
  const invoke = async (args: string[]) => {
@@ -568,7 +575,7 @@ export function createTrailIntegration(options: Options) {
568
575
  // they only skip the binary probe, which a one-shot run cannot report on.
569
576
  // Returning here instead used to leave those sessions without any map tool.
570
577
  const probe = !child && (isEligibleTrailStartup(event, ctx) || ["new", "reload", "resume", "fork"].includes(event?.reason));
571
- state.generation++; state.refresh = undefined; state.dirty = false; state.buildAttempted = false; state.root = resolveWorktreeRoot(ctx.cwd); const root = state.root; state.repoMapShown = false; state.gate = { active: false, retrieved: false, pointers: new Set<string>() }; state.trailReads = 0; state.sourceReads = 0;
578
+ state.generation++; state.refresh = undefined; state.dirty = false; state.buildAttempted = false; state.warmed = false; state.coldAttempts = 0; state.root = resolveWorktreeRoot(ctx.cwd); const root = state.root; state.repoMapShown = false; state.gate = { active: false, retrieved: false, pointers: new Set<string>() }; state.trailReads = 0; state.sourceReads = 0;
572
579
  state.enabled = true; loadMap(root); state.value = "syncing";
573
580
  try { state.store = savingsStore(ctx, root, log); state.dirtySignal = state.store ? `${state.store}.dirty` : ""; if (state.dirtySignal) { mkdirSync(state.dirtySignal, { recursive: true, mode: 0o700 }); chmodSync(state.dirtySignal, 0o700); } state.tokenSavings = readSavings(state.store); } catch (error: any) { state.store = ""; state.dirtySignal = ""; state.tokenSavings = 0; log.warn("savings_restore_failed", { code: error?.code }); }
574
581
  // Provisional state while we probe. This used to be "failed", which made
@@ -624,7 +631,7 @@ export function createTrailIntegration(options: Options) {
624
631
  log.info("gate_satisfied", { by: tool.startsWith("trail_") ? "tool" : "auto_pointer_read", tool });
625
632
  }
626
633
  const kind = !event.isError ? classifyToolUse(event.toolName, event.input) : null;
627
- const savings = !event.isError ? savedTokens(toolText(event.content)) : 0;
634
+ const savings = !event.isError ? savedTokens(toolText(event.content), log) : 0;
628
635
  if (kind === "trail") state.trailReads++; else if (kind === "source") state.sourceReads++;
629
636
  addSavings(savings);
630
637
  if (kind || savings) { setState(state.dirty ? "stale" : state.value === "absent" || state.value === "failed" ? state.value : "ready"); log.info("session_metric", { tool: event.toolName, kind, savings, trailReads: state.trailReads, sourceReads: state.sourceReads, tokenSavings: state.tokenSavings }); }
@@ -682,7 +689,14 @@ export function createTrailIntegration(options: Options) {
682
689
  if (!query || !substantive(query)) { finish("guidance_only_control_prompt"); return guidance; }
683
690
  state.gate.active = true;
684
691
  // One shared budget: a fallback retry must not double the turn's deadline.
685
- const deadline = started + (options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS);
692
+ // `warmed` only flips on a successful retrieval, so a cold attempt that
693
+ // times out or fails keeps the cold ceiling; the attempt cap stops a
694
+ // genuinely broken CLI from paying it forever.
695
+ const cold = !state.warmed && state.coldAttempts < 2;
696
+ if (cold) state.coldAttempts++;
697
+ const budget = options.interactiveTimeoutMs ?? (cold ? (options.coldTimeoutMs ?? COLD_RETRIEVAL_TIMEOUT_MS) : INTERACTIVE_TIMEOUT_MS);
698
+ log.debug("retrieval_budget", { cold, budgetMs: budget, attempt: state.coldAttempts });
699
+ const deadline = started + budget;
686
700
  const remaining = () => Math.max(1, deadline - Date.now());
687
701
  type Attempt = { hits: Hit[]; failure?: { decision: string; note: string; diagnostic?: string } };
688
702
  /** One bounded retrieval attempt; null means the session changed under us. */
@@ -690,15 +704,17 @@ export function createTrailIntegration(options: Options) {
690
704
  if (options.retrieve) {
691
705
  const result = await withTimeout(options.retrieve(value, root), remaining(), log);
692
706
  if (!current()) return null;
707
+ state.warmed = true;
693
708
  return { hits: result.pointers.map(pointer => ({ title: pointer, pointer })) };
694
709
  }
695
710
  // `--source` inlines the code, so a hit can be used without re-opening the file.
696
711
  const result = await withTimeout(run(["ask", value, ".", "--json", "-n", "3", "--source"], { cwd: root, timeoutMs: remaining() }), remaining(), log);
697
712
  if (!current()) return null;
698
- if (result.code) { const reason = diagnostic(result.stderr); return { hits: [], failure: { decision: "ask_failed", diagnostic: reason, note: `[trail] Retrieval failed: ${reason}. Raw navigation is available.` } }; }
699
- const savings = savedTokens(result.stdout); addSavings(savings); setState(state.value); loadMap(root);
713
+ if (result.code) { const reason = diagnostic(result.stderr); return { hits: [], failure: { decision: "ask_failed", diagnostic: reason, note: `[trail] Retrieval failed: ${reason}. Call trail_find_code (describe the concept, not an identifier) for the map answer; raw navigation is available if it returns nothing relevant.` } }; }
714
+ const savings = savedTokens(result.stdout, log); addSavings(savings); setState(state.value); loadMap(root);
700
715
  const ask = parseAsk(result.stdout);
701
- if (!ask) return { hits: [], failure: { decision: "invalid_ask_json", note: "[trail] Retrieval returned malformed output. Raw navigation is available." } };
716
+ if (!ask) return { hits: [], failure: { decision: "invalid_ask_json", note: "[trail] Retrieval returned malformed output. Call trail_find_code (describe the concept, not an identifier) for the map answer; raw navigation is available if it returns nothing relevant." } };
717
+ state.warmed = true;
702
718
  return { hits: relevant(ask) };
703
719
  };
704
720
  let hits: Hit[] = [];
@@ -716,7 +732,7 @@ export function createTrailIntegration(options: Options) {
716
732
  if (!result) return;
717
733
  if (result.failure) { state.gate.active = false; finish(result.failure.decision, result.failure.diagnostic ? { diagnostic: result.failure.diagnostic } : {}); return inject(event, [], result.failure.note); }
718
734
  hits = result.hits;
719
- } catch (error: any) { if (!current()) return; state.gate.active = false; finish("ask_error"); return inject(event, [], `[trail] Retrieval failed: ${text(error?.message, 240) ?? "unknown error"}. Raw navigation is available.`); }
735
+ } catch (error: any) { if (!current()) return; state.gate.active = false; finish("ask_error"); return inject(event, [], `[trail] Retrieval failed: ${text(error?.message, 240) ?? "unknown error"}. Call trail_find_code (describe the concept, not an identifier) for the map answer; raw navigation is available if it returns nothing relevant.`); }
720
736
  // Still no map on disk (the seed found nothing, or this was never a
721
737
  // worktree): fail open rather than blocking navigation on a map we lack.
722
738
  if (!state.mapPaths.size) { state.gate.active = false; log.info("gate_skipped", { reason: "no_map" }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.22.1",
3
+ "version": "4.22.3",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"