pi-orchestrator-config 4.22.2 → 4.22.4
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/agents/git-expert.md +14 -5
- package/extensions/orchestrator/enforcement-helpers.ts +356 -34
- package/extensions/orchestrator/enforcement.ts +126 -68
- package/extensions/orchestrator/git-helpers.ts +27 -14
- package/package.json +1 -1
- package/rules/00-orchestrator-core.md +2 -0
- package/rules/60-task-tracking.md +17 -38
package/agents/git-expert.md
CHANGED
|
@@ -44,11 +44,20 @@ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
|
|
|
44
44
|
{{SETTINGS:dco,commit_trailer,use_worktrees,allow_push_to_protected_branches}}
|
|
45
45
|
|
|
46
46
|
- If `dco` is `true`: add `--signoff` to `git commit`. Skip if already present.
|
|
47
|
-
- If `commit_trailer` is a string (e.g. `"Assisted-by"`):
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
- If `commit_trailer` is a string (e.g. `"Assisted-by"`): every commit you make MUST carry that
|
|
48
|
+
trailer, and who writes it depends on the commit shape:
|
|
49
|
+
- `git commit -m "…"` → the enforcement hook appends `TRAILER: PI (<model>)`.
|
|
50
|
+
Do not write it yourself.
|
|
51
|
+
- `echo … | git commit -F -` → the hook appends it. Do not write it yourself.
|
|
52
|
+
- `git commit -F <file>` (message written to a file, including heredoc-to-file) → the hook never
|
|
53
|
+
sees the file's contents, so append the trailer yourself with the already-resolved id:
|
|
54
|
+
`printf '\n\nTRAILER: PI (%s)\n' "$PI_MODEL" >> <file>` and then commit. The shell expands
|
|
55
|
+
`$PI_MODEL` while writing the file, so no placeholder can reach the message.
|
|
56
|
+
Never write a bare `MODEL` placeholder and never leave an unexpanded `$PI_MODEL` in a `-m` message:
|
|
57
|
+
the enforcement hook **refuses** such a commit (it reports the placeholder line back), and it also
|
|
58
|
+
refuses a commit shape it cannot inspect when the message shows no trailer. The fix is always to
|
|
59
|
+
write the trailer yourself with the real id, then commit again.
|
|
60
|
+
Skip only when the message already carries that trailer.
|
|
52
61
|
- If `use_worktrees` is `true`: BLOCK `git checkout` (except `git checkout -- <file>`) and `git switch`.
|
|
53
62
|
Use `git worktree add .worktrees/NAME -b BRANCH DEFAULT_BRANCH` instead
|
|
54
63
|
(detect default branch via `git symbolic-ref refs/remotes/origin/HEAD`, fallback to `main`).
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
353
|
-
enfLog.warn("
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
371
|
-
enfLog.warn("
|
|
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
|
-
|
|
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 (
|
|
394
|
-
enfLog.warn("
|
|
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
|
-
|
|
2043
|
-
|
|
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,
|
|
@@ -2363,24 +2606,103 @@ function bodyAlreadySigned(text: string): boolean {
|
|
|
2363
2606
|
return false;
|
|
2364
2607
|
}
|
|
2365
2608
|
|
|
2366
|
-
// Trailer-shaped line: `Token:` at line start (real newline, literal \n
|
|
2367
|
-
//
|
|
2368
|
-
//
|
|
2369
|
-
//
|
|
2370
|
-
const
|
|
2371
|
-
|
|
2372
|
-
/**
|
|
2373
|
-
*
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
* Scoped to `git commit` commands and to trailer-shaped lines only, so
|
|
2377
|
-
* commit-message prose that legitimately mentions `$PI_MODEL` — bare or
|
|
2378
|
-
* mid-sentence — and unrelated parts of compound commands are untouched. */
|
|
2379
|
-
export function fixUnexpandedCommitTrailer(command: string, modelId: string): string | null {
|
|
2380
|
-
if (!command.includes("$PI_MODEL")) return null;
|
|
2609
|
+
// Trailer-shaped line: `Token:` at line start (real newline, literal \n escape, or
|
|
2610
|
+
// command start), then a PI trailer identity that is a PLACEHOLDER rather than a model
|
|
2611
|
+
// id. Detection only — the hook refuses such a commit instead of rewriting text it may
|
|
2612
|
+
// have misparsed (Qodo, PR #1004).
|
|
2613
|
+
const placeholderTrailerLine = /(^|\n|\\n|-m\s+['"])([ \t]*[A-Za-z][A-Za-z0-9-]*:[ \t]*)PI \(\$?\{?PI_MODEL\}?|MODEL\)/g;
|
|
2614
|
+
|
|
2615
|
+
/** The trailer line carrying an unresolved placeholder, or null when the command has none.
|
|
2616
|
+
* A `Token:` anchor keeps prose that merely mentions the placeholder out of the result. */
|
|
2617
|
+
export function findPlaceholderTrailer(command: string): string | null {
|
|
2618
|
+
if (!/(?:PI_MODEL|\bMODEL\b)/.test(command)) return null;
|
|
2381
2619
|
if (!/\bgit\s+commit\b/.test(command)) return null;
|
|
2382
|
-
const
|
|
2383
|
-
|
|
2620
|
+
const matches = [...command.matchAll(placeholderTrailerLine)];
|
|
2621
|
+
const found = matches.at(-1);
|
|
2622
|
+
if (!found) {
|
|
2623
|
+
enfLog.debug("placeholder trailer none");
|
|
2624
|
+
return null;
|
|
2625
|
+
}
|
|
2626
|
+
const line = `${(found[2] ?? "").trim()} PI (placeholder)`;
|
|
2627
|
+
enfLog.warn("placeholder trailer found", line);
|
|
2628
|
+
return line;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/** Trailers where git itself needs an address: `Co-authored-by` credits a person. */
|
|
2632
|
+
const TRAILERS_REQUIRING_EMAIL = new Set(["co-authored-by"]);
|
|
2633
|
+
|
|
2634
|
+
/** The value appended after `<Trailer>:`. `Assisted-by` needs no address, `Co-authored-by`
|
|
2635
|
+
* does — hosting integrations only credit a co-author through `Name <email>`. */
|
|
2636
|
+
export function commitTrailerIdentity(trailerName: string, modelId: string): string {
|
|
2637
|
+
const needsEmail = TRAILERS_REQUIRING_EMAIL.has(trailerName.trim().toLowerCase());
|
|
2638
|
+
const identity = needsEmail ? `PI (${modelId}) <noreply@pi.dev>` : `PI (${modelId})`;
|
|
2639
|
+
enfLog.debug("commit_trailer_identity", trailerName, needsEmail);
|
|
2640
|
+
return identity;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/** Does a `-F <file>` commit message already carry the trailer?
|
|
2644
|
+
* The caller injects the reader so the decision is testable without touching disk —
|
|
2645
|
+
* the hook reads the file, it does not guess from the command text (Qodo, PR #1004). */
|
|
2646
|
+
export function messageFileTrailerState(
|
|
2647
|
+
filePath: string,
|
|
2648
|
+
trailerName: string,
|
|
2649
|
+
read: (path: string) => string,
|
|
2650
|
+
): "has" | "missing" | "unreadable" {
|
|
2651
|
+
try {
|
|
2652
|
+
const state = commandHasTrailerByName(read(filePath).slice(0, 64_000), trailerName) ? "has" : "missing";
|
|
2653
|
+
enfLog.debug("message_file_trailer", filePath, state);
|
|
2654
|
+
return state;
|
|
2655
|
+
} catch (error: any) {
|
|
2656
|
+
// warn, not debug: trailer verification could not inspect the message, which is a
|
|
2657
|
+
// degraded guarantee an operator needs to see (Qodo, PR #1004).
|
|
2658
|
+
enfLog.warn("message_file_trailer unreadable", filePath, String(error?.message ?? error));
|
|
2659
|
+
return "unreadable";
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
/** Append `<Trailer>: PI (<model>)` to the message of a real commit invocation.
|
|
2664
|
+
* Covers the two shapes whose message the hook can see — `echo … | git commit -F -`
|
|
2665
|
+
* and `git commit -m "…"` — and returns null when the trailer is already there or
|
|
2666
|
+
* the shape is not covered. Pure so the injection path is testable without a hook. */
|
|
2667
|
+
export function injectCommitTrailer(command: string, trailerName: string, modelId: string): string | null {
|
|
2668
|
+
const identity = commitTrailerIdentity(trailerName, modelId);
|
|
2669
|
+
// Pattern A: echo "..." | git commit -F -
|
|
2670
|
+
const pipeIdx = command.lastIndexOf("|");
|
|
2671
|
+
if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
|
|
2672
|
+
const echoPart = command.slice(0, pipeIdx);
|
|
2673
|
+
const gitPart = command.slice(pipeIdx);
|
|
2674
|
+
const lastQuoteIdx = Math.max(echoPart.lastIndexOf('"'), echoPart.lastIndexOf("'"));
|
|
2675
|
+
if (lastQuoteIdx > 0) {
|
|
2676
|
+
if (commandHasTrailerByName(echoPart, trailerName)) {
|
|
2677
|
+
enfLog.debug("inject_commit_trailer", "already present", trailerName);
|
|
2678
|
+
return null;
|
|
2679
|
+
}
|
|
2680
|
+
const quoteChar = echoPart[lastQuoteIdx];
|
|
2681
|
+
const escaped = quoteChar === "'"
|
|
2682
|
+
? `${escapeForSingleQuote(trailerName)}: ${identity}`
|
|
2683
|
+
: `${escapeForDoubleQuote(trailerName)}: ${identity}`;
|
|
2684
|
+
enfLog.info("inject_commit_trailer", "piped", trailerName);
|
|
2685
|
+
return echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
|
|
2686
|
+
}
|
|
2687
|
+
return null;
|
|
2688
|
+
}
|
|
2689
|
+
// Pattern B: git commit -m "..." or git commit -m '...'
|
|
2690
|
+
const mFlagMatch = command.match(/git\s+commit\s+.*-m\s+(["'])([\s\S]*?)\1/);
|
|
2691
|
+
if (!mFlagMatch) return null;
|
|
2692
|
+
// Every `-m` is a message paragraph, so an existing trailer in ANY of them wins.
|
|
2693
|
+
const allPayloads = [...command.matchAll(/-m\s+(["'])([\s\S]*?)\1/g)].map((m) => m[2] ?? "").join("\n");
|
|
2694
|
+
if (commandHasTrailerByName(allPayloads, trailerName)) {
|
|
2695
|
+
enfLog.debug("inject_commit_trailer", "already present", trailerName);
|
|
2696
|
+
return null;
|
|
2697
|
+
}
|
|
2698
|
+
const quoteChar = mFlagMatch[1];
|
|
2699
|
+
const escaped = quoteChar === "'"
|
|
2700
|
+
? `${escapeForSingleQuote(trailerName)}: ${identity}`
|
|
2701
|
+
: `${escapeForDoubleQuote(trailerName)}: ${identity}`;
|
|
2702
|
+
const fullMatch = mFlagMatch[0];
|
|
2703
|
+
const insertPos = command.indexOf(fullMatch) + fullMatch.length - 1;
|
|
2704
|
+
enfLog.info("inject_commit_trailer", "inline", trailerName);
|
|
2705
|
+
return command.slice(0, insertPos) + `\n\n${escaped}` + command.slice(insertPos);
|
|
2384
2706
|
}
|
|
2385
2707
|
|
|
2386
2708
|
/** Keep the first standalone valid footer, but use the current operation's model. */
|
|
@@ -20,7 +20,8 @@ import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
|
|
|
20
20
|
import {
|
|
21
21
|
conflictCandidateDirs,
|
|
22
22
|
pushTargetsProtectedBranch,
|
|
23
|
-
|
|
23
|
+
findPlaceholderTrailer,
|
|
24
|
+
messageFileTrailerState,
|
|
24
25
|
maskQuotedRegions,
|
|
25
26
|
isStdinFedShellStatement,
|
|
26
27
|
} from "./enforcement-helpers.js";
|
|
@@ -46,12 +47,17 @@ import {
|
|
|
46
47
|
checkPythonPipBlock,
|
|
47
48
|
checkRemoteExecBlock,
|
|
48
49
|
checkTempFileEnforcement,
|
|
50
|
+
bashWritesToPath,
|
|
49
51
|
gitAddPathspecs,
|
|
52
|
+
gitIdentityConfigSrc,
|
|
53
|
+
gitIdentityOverride,
|
|
50
54
|
gitSubArgRegions,
|
|
51
55
|
hasGitAddBulk,
|
|
52
56
|
hasGitAddForce,
|
|
57
|
+
injectCommitTrailer,
|
|
53
58
|
injectSignoff,
|
|
54
59
|
injectGhBodySignature,
|
|
60
|
+
isGitIdentityConfigPath,
|
|
55
61
|
isReadOnlyStatement,
|
|
56
62
|
isRealGitCommitOrPush,
|
|
57
63
|
isRmInProjectTmp,
|
|
@@ -114,9 +120,23 @@ function writeRepeatState(state: { lastCmd: string; count: number }): void {
|
|
|
114
120
|
|
|
115
121
|
|
|
116
122
|
|
|
123
|
+
/** Placeholder strings that are never a real model id — printing one in a trailer ships a bug. */
|
|
124
|
+
const PLACEHOLDER_MODEL_IDS = new Set(["model", "pi_model", "$pi_model", "${pi_model}", "unknown"]);
|
|
125
|
+
|
|
117
126
|
/** Resolve the model id used in commit/PR trailers (env first, then ctx). */
|
|
118
|
-
function commitTrailerModelId(ctx: any): string {
|
|
119
|
-
|
|
127
|
+
export function commitTrailerModelId(ctx: any): string {
|
|
128
|
+
for (const raw of [process.env.PI_PRIMARY_MODEL, process.env.PI_MODEL, (ctx as any).model?.id]) {
|
|
129
|
+
const trimmed = (raw ?? "").trim();
|
|
130
|
+
if (!trimmed) continue;
|
|
131
|
+
if (PLACEHOLDER_MODEL_IDS.has(trimmed.toLowerCase())) {
|
|
132
|
+
log.debug("commit_trailer: skipped placeholder model id", trimmed);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const clean = trimmed.replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim();
|
|
136
|
+
if (clean) return clean;
|
|
137
|
+
}
|
|
138
|
+
log.warn("commit_trailer: no model id resolved — trailer identity is unknown");
|
|
139
|
+
return "unknown";
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
/** Inject commit trailer into command (Assisted-by, Co-authored-by, etc.) */
|
|
@@ -124,18 +144,20 @@ function commitTrailerModelId(ctx: any): string {
|
|
|
124
144
|
let bridgedUi: ReturnType<typeof createBridgedUi> | null = null;
|
|
125
145
|
|
|
126
146
|
async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd: string): Promise<EnforcementResult> {
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
// A placeholder trailer is REFUSED, never rewritten: the hook cannot safely tell
|
|
148
|
+
// command text from message prose (a commit message quoting the very shape it
|
|
149
|
+
// parses, a `;` inside quotes, indented examples), and rewriting the wrong span
|
|
150
|
+
// corrupts a message or edits an unrelated command. The agent has the text, so it
|
|
151
|
+
// fixes it and retries (Qodo, PR #1004).
|
|
152
|
+
const placeholder = findPlaceholderTrailer(command);
|
|
153
|
+
if (placeholder) {
|
|
154
|
+
return {
|
|
155
|
+
block: true,
|
|
156
|
+
reason:
|
|
157
|
+
`⛔ Commit refused: the trailer carries an unresolved placeholder (${placeholder}). ` +
|
|
158
|
+
`Write \`TRAILER: PI (${commitTrailerModelId(ctx)})\` instead of a MODEL/$PI_MODEL placeholder ` +
|
|
159
|
+
"and re-run the commit.",
|
|
160
|
+
};
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
const trailerSetting = getSetting(gitCwd, "commit_trailer");
|
|
@@ -143,7 +165,6 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
|
|
|
143
165
|
|
|
144
166
|
const modelId = commitTrailerModelId(ctx);
|
|
145
167
|
|
|
146
|
-
const piIdentity = `PI (${modelId}) <noreply@pi.dev>`;
|
|
147
168
|
let trailerName: string;
|
|
148
169
|
|
|
149
170
|
if (trailerSetting.includes(",")) {
|
|
@@ -180,55 +201,54 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
|
|
|
180
201
|
trailerName = trailerSetting;
|
|
181
202
|
}
|
|
182
203
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const gitPart = command.slice(pipeIdx);
|
|
188
|
-
const lastDoubleQuote = echoPart.lastIndexOf('"');
|
|
189
|
-
const lastSingleQuote = echoPart.lastIndexOf("'");
|
|
190
|
-
const lastQuoteIdx = Math.max(lastDoubleQuote, lastSingleQuote);
|
|
191
|
-
if (lastQuoteIdx > 0) {
|
|
192
|
-
// Determine quote context for correct escaping
|
|
193
|
-
const quoteChar = echoPart[lastQuoteIdx];
|
|
194
|
-
// Scope dedup to the echo/message-construction portion (before the pipe), which
|
|
195
|
-
// excludes the git args. Do NOT attempt to slice out the exact quoted substring
|
|
196
|
-
// by backward-scanning for a quote — escaped quotes (\") inside the message would
|
|
197
|
-
// misparse the boundary and cause a missed trailer -> duplicate injection.
|
|
198
|
-
if (commandHasTrailerByName(echoPart, trailerName)) {
|
|
199
|
-
log.debug("commit_trailer already present in echo message (by name) — skipping injection", trailerName);
|
|
200
|
-
return undefined;
|
|
201
|
-
}
|
|
202
|
-
const escaped = quoteChar === "'"
|
|
203
|
-
? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
|
|
204
|
-
: `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
|
|
205
|
-
event.input.command =
|
|
206
|
-
echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
|
|
207
|
-
}
|
|
204
|
+
const injected = injectCommitTrailer(command, trailerName, modelId);
|
|
205
|
+
if (injected !== null) {
|
|
206
|
+
event.input.command = injected;
|
|
207
|
+
return undefined;
|
|
208
208
|
}
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
209
|
+
// `git commit -F <file>`: the message is a file we CAN read, so check it instead of
|
|
210
|
+
// guessing. Refusing a message that already carries the trailer would block the very
|
|
211
|
+
// flow the git-expert instruction describes (Qodo, PR #1004).
|
|
212
|
+
const fileMessage = command.match(/git\s+commit\b[^\n]*?-F\s+(?!-)(["']?)([^\s"']+)\1/);
|
|
213
|
+
if (fileMessage && fileMessage[2]) {
|
|
214
|
+
// The read happens BEFORE the command runs, so a command that writes the same file
|
|
215
|
+
// would be verified against its OLD contents while git commits the new ones. Refuse
|
|
216
|
+
// that shape instead of trusting the stale read (Qodo, PR #1004).
|
|
217
|
+
const filePathSrc = fileMessage[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
218
|
+
if (bashWritesToPath(command, maskQuotedRegions(command), filePathSrc)) {
|
|
219
|
+
log.warn("commit_trailer: message file is written by the same command", fileMessage[2]);
|
|
220
|
+
return {
|
|
221
|
+
block: true,
|
|
222
|
+
reason:
|
|
223
|
+
`⛔ Commit refused: this command writes ${fileMessage[2]} and commits it in the same call, ` +
|
|
224
|
+
"so the message cannot be verified before it is read. Write the message (with the trailer) in one call and commit in the next.",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const candidate = path.isAbsolute(fileMessage[2]) ? fileMessage[2] : join(gitCwd, fileMessage[2]);
|
|
228
|
+
const state = messageFileTrailerState(candidate, trailerName, (p) => readFileSync(p, "utf8"));
|
|
229
|
+
if (state === "has") {
|
|
230
|
+
log.debug("commit_trailer: message file already carries the trailer", candidate);
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
if (state === "missing") {
|
|
234
|
+
return {
|
|
235
|
+
block: true,
|
|
236
|
+
reason:
|
|
237
|
+
`⛔ Commit refused: the message file ${fileMessage[2]} has no \`${trailerName}\` trailer. ` +
|
|
238
|
+
`Append it with the real id — \`printf '\\n\\n" + trailerName + ": PI (%s)\\n' \"$PI_MODEL\" >> " + fileMessage[2] + "\` — then re-run the commit.`,
|
|
239
|
+
};
|
|
229
240
|
}
|
|
230
241
|
}
|
|
231
|
-
|
|
242
|
+
// Other opaque shapes must carry the trailer already; refusing beats guessing.
|
|
243
|
+
if (!commandHasTrailerByName(command, trailerName)) {
|
|
244
|
+
return {
|
|
245
|
+
block: true,
|
|
246
|
+
reason:
|
|
247
|
+
`⛔ Commit refused: no \`${trailerName}\` trailer found and this commit shape is opaque to the hook ` +
|
|
248
|
+
`(the message is built in a way it cannot inspect). Add \`${trailerName}: PI (${modelId})\` to the message ` +
|
|
249
|
+
"yourself — e.g. `printf '\\n\\n" + trailerName + ": PI (%s)\\n' \"$PI_MODEL\" >> <file>` for `-F <file>` — then re-run the commit.",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
232
252
|
return undefined;
|
|
233
253
|
}
|
|
234
254
|
|
|
@@ -755,6 +775,17 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
|
|
|
755
775
|
"\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
776
|
};
|
|
757
777
|
}
|
|
778
|
+
// Git identity config is off-limits to agents too (issue #995): the
|
|
779
|
+
// identity a commit is recorded under belongs to the human, so no agent
|
|
780
|
+
// writes it by hand — same alias-proof resolution as the settings guard.
|
|
781
|
+
if (isGitIdentityConfigPath(resolved) || isGitIdentityConfigPath(toReal(resolved))) {
|
|
782
|
+
return {
|
|
783
|
+
block: true,
|
|
784
|
+
reason:
|
|
785
|
+
"\u26d4 Git identity config is off-limits to agents. The git identity belongs to the human — " +
|
|
786
|
+
"if it is wrong, tell the user to fix their own git config.",
|
|
787
|
+
};
|
|
788
|
+
}
|
|
758
789
|
}
|
|
759
790
|
}
|
|
760
791
|
|
|
@@ -788,12 +819,7 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
|
|
|
788
819
|
const maskedCmd = maskQuotedRegions(command);
|
|
789
820
|
if (
|
|
790
821
|
cmdLower.includes("pi-config-settings") &&
|
|
791
|
-
(
|
|
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))
|
|
822
|
+
bashWritesToPath(command, maskedCmd, "pi-config-settings")
|
|
797
823
|
) {
|
|
798
824
|
return {
|
|
799
825
|
block: true,
|
|
@@ -802,6 +828,21 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
|
|
|
802
828
|
};
|
|
803
829
|
}
|
|
804
830
|
|
|
831
|
+
// Block bash WRITES targeting a git identity config file (issue #995) —
|
|
832
|
+
// same narrow tripwire shape as the settings one above. The home files
|
|
833
|
+
// count only under THIS user's home: a `.gitconfig` in /tmp is not the
|
|
834
|
+
// identity git reads (Qodo, PR #996). One pattern, shared with the tests.
|
|
835
|
+
const IDENTITY_CONFIG_SRC = gitIdentityConfigSrc();
|
|
836
|
+
if (bashWritesToPath(command, maskedCmd, IDENTITY_CONFIG_SRC)) {
|
|
837
|
+
log.error("git_identity_config_write_blocked");
|
|
838
|
+
return {
|
|
839
|
+
block: true,
|
|
840
|
+
reason:
|
|
841
|
+
"\u26d4 Bash command writing a git identity config (~/.gitconfig, ~/.config/git/config, .git/config) blocked. " +
|
|
842
|
+
"The git identity belongs to the human — if it is wrong, tell the user to fix their own git config.",
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
805
846
|
// Block repeated identical commands (polling-by-spam) — orchestrator only.
|
|
806
847
|
// Skip on acpx: async LLM agents are unavailable; foreground polls are expected.
|
|
807
848
|
const parentSupportsAsyncLlm = supportsAsyncLlm(
|
|
@@ -896,6 +937,23 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
|
|
|
896
937
|
}
|
|
897
938
|
}
|
|
898
939
|
|
|
940
|
+
// The git identity belongs to the human (issue #995): an agent that can set
|
|
941
|
+
// user.name/user.email can forge a commit in someone else's name. Checked
|
|
942
|
+
// before the git-expert split below so it holds for EVERY agent, and before
|
|
943
|
+
// any git-repo lookup so it holds outside a repo too.
|
|
944
|
+
const identityOverride = gitIdentityOverride(command);
|
|
945
|
+
if (identityOverride) {
|
|
946
|
+
// error, not warn: the command was refused, not recovered from.
|
|
947
|
+
log.error("git_identity_override_blocked", "source", identityOverride);
|
|
948
|
+
return {
|
|
949
|
+
block: true,
|
|
950
|
+
reason:
|
|
951
|
+
`\u26d4 Git identity override blocked (${identityOverride}). The git identity belongs to the human: ` +
|
|
952
|
+
"never set user.name/user.email, GIT_AUTHOR_*/GIT_COMMITTER_*, --author, or `git config user.*`. " +
|
|
953
|
+
"If the identity is wrong, tell the user to fix their own git config.",
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
899
957
|
// Enforce git commit/push only via git-expert agent.
|
|
900
958
|
// Use isRealGitCommitOrPush (heredoc-stripped + command-position aware) so that
|
|
901
959
|
// 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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
897
|
-
|
|
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
|
package/package.json
CHANGED
|
@@ -34,6 +34,8 @@ Outside slash commands, never use edit, write, or bash directly (except `codemod
|
|
|
34
34
|
|
|
35
35
|
## Before Implementation (MANDATORY)
|
|
36
36
|
|
|
37
|
+
**For 2+ distinct actions, your first action is one `TaskBulkCreate` listing every step** — before the first edit or the first agent call (see `60-task-tracking.md`).
|
|
38
|
+
|
|
37
39
|
Before ANY code changes, run the pre-implementation checklist:
|
|
38
40
|
|
|
39
41
|
→ **See the "Pre-Implementation Checklist" section below** — Do NOT skip this step.
|
|
@@ -7,58 +7,37 @@
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
## When
|
|
10
|
+
## When Tasks Are Required
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
The request needs **2+ distinct actions** — then tasks are required:
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
- more than one file to change, or code plus tests plus docs
|
|
15
|
+
- any workflow from `05-issue-first-workflow.md` or `20-code-review-loop.md`
|
|
16
|
+
- the user lists several things to do ("do X, then Y", numbered items)
|
|
17
|
+
- work conditioned on a future event ("after Qodo approves, deploy and test") — create the tasks now, put the trigger in the description, and block the dependents on it
|
|
17
18
|
|
|
18
|
-
**
|
|
19
|
-
conditional on a future event ("after X", "once Y", "when Z"), create the
|
|
20
|
-
tasks IMMEDIATELY in that same turn — the trigger condition goes in the task
|
|
21
|
-
description, and dependent tasks block on the trigger task. Never carry a
|
|
22
|
-
deferred mission only in conversation memory: compaction or a session
|
|
23
|
-
restart loses it silently.
|
|
19
|
+
**No tasks:** a single-step action, a one-file/one-line change, a question, an explanation, read-only investigation.
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
- ✅ GOOD: `TaskCreate("Deploy X locally — trigger: Qodo R6 clean")` + blocked follow-ups
|
|
21
|
+
## First Action
|
|
27
22
|
|
|
28
|
-
**
|
|
23
|
+
Plan the whole job in **one `TaskBulkCreate` call** listing every step — not one `TaskCreate` per step, and not after the first edit. `TaskCreate` is for a single step added later.
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## Task Granularity
|
|
25
|
+
When `TaskCreate`'s own tool description suggests per-step calls, **this rule wins**: one bulk call for the whole job.
|
|
33
26
|
|
|
34
|
-
|
|
27
|
+
## Granularity
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
- ✅ GOOD: `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Run code review loop (3 async reviewers)` / `Create PR with description`
|
|
29
|
+
One concrete action per task: which file, which change.
|
|
38
30
|
|
|
39
|
-
|
|
31
|
+
- ❌ `Implement the fix` / `Create PR`
|
|
32
|
+
- ✅ `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Create PR with the test plan`
|
|
40
33
|
|
|
41
|
-
##
|
|
34
|
+
## Lifecycle
|
|
42
35
|
|
|
43
|
-
|
|
44
|
-
Mark each `in_progress` before starting and `completed` immediately after finishing via `TaskUpdate`.
|
|
45
|
-
Work through tasks in order — do not skip tasks or start new work while unchecked tasks exist (unless user explicitly pivots).
|
|
46
|
-
|
|
47
|
-
---
|
|
36
|
+
`in_progress` before starting a task, `completed` immediately after. Work in order. Never abandon a task — drop a stale one with `TaskUpdate status: "deleted"`.
|
|
48
37
|
|
|
49
38
|
## Side Questions
|
|
50
39
|
|
|
51
|
-
Answer the question, then resume the next unchecked task
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## Integration & Key Rules
|
|
56
|
-
|
|
57
|
-
Task tracking works alongside existing workflow rules — include issue-first, code review, and documentation steps as individual tasks.
|
|
58
|
-
{{IF:task_reminder_enabled}}
|
|
59
|
-
Tasks are code-enforced (reminders after 4+ ignored turns).
|
|
60
|
-
{{/IF}}
|
|
61
|
-
Never abandon tasks — if scope changes, use `TaskUpdate` with `status: "deleted"` to remove obsolete tasks; the task list is your contract.
|
|
40
|
+
Answer the question, then resume the next unchecked task — the task list says where you left off.
|
|
62
41
|
|
|
63
42
|
---
|
|
64
43
|
|