devrites 3.2.23 → 3.2.24

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.2.24](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.23...v3.2.24) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** close review safety gaps ([33e736a](https://github.com/ViktorsBaikers/DevRites/commit/33e736a3d9d69fd166827235ed1ce2310aeca75a))
10
+
5
11
  ## [3.2.23](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.22...v3.2.23) (2026-07-28)
6
12
 
7
13
  ### Fixed
package/README.md CHANGED
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
25
25
  Unattended runs may create local WIP checkpoint commits along the way, but only
26
26
  Ship collapses and pushes them.
27
27
 
28
- **Status:** [`v3.2.23`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.23): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.24`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.24): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
package/engine/hooks.go CHANGED
@@ -389,18 +389,37 @@ func safeReadonlyShellSegment(segment string) bool {
389
389
  base := strings.ToLower(filepath.Base(strings.Trim(fields[0], `"'`)))
390
390
  args := fields[1:]
391
391
  switch base {
392
- case "true", "false", "pwd", "ls", "cat", "head", "tail", "less", "more",
393
- "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "stat", "file",
394
- "readlink", "realpath", "basename", "dirname", "cmp", "diff", "jq", "yq",
395
- "tree", "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
392
+ case "true", "false", "pwd", "ls", "cat", "head", "tail", "more",
393
+ "grep", "wc", "uniq", "cut", "tr", "stat", "file",
394
+ "readlink", "realpath", "basename", "dirname", "cmp", "jq",
395
+ "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
396
396
  "ps", "echo", "printf", "test", "[", "cd", "sha256sum", "shasum":
397
397
  return true
398
+ case "less":
399
+ return !hasShellOption(args, "oO", "--log-file")
400
+ case "rg":
401
+ return !hasShellOption(args, "", "--pre", "--hostname-bin")
402
+ case "sort":
403
+ return !hasShellOption(args, "o", "--output", "--compress-program")
404
+ case "diff":
405
+ return !hasShellOption(args, "", "--output")
406
+ case "yq":
407
+ return !hasShellOption(args, "i", "--inplace", "--in-place")
408
+ case "tree":
409
+ return !hasShellOption(args, "o", "--output")
398
410
  case "find":
399
- return !hasAnyArg(args, "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf")
411
+ return !hasArgPrefix(args, "-delete") &&
412
+ !hasArgPrefix(args, "-exec") &&
413
+ !hasArgPrefix(args, "-ok") &&
414
+ !hasArgPrefix(args, "-fprint") &&
415
+ !hasArgPrefix(args, "-fprintf") &&
416
+ !hasArgPrefix(args, "-fls")
400
417
  case "sed":
401
- return !hasArgPrefix(args, "-i")
418
+ return !hasShellOption(args, "i", "--in-place", "--inplace")
402
419
  case "git":
403
- return len(args) > 0 && hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame")
420
+ return len(args) > 0 &&
421
+ hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame") &&
422
+ !hasShellOption(args[1:], "", "--output", "--ext-diff", "--textconv", "--open-files-in-pager")
404
423
  case "go":
405
424
  if len(args) == 0 || !hasAnyArg(args[:1], "test", "vet", "list", "version", "env") {
406
425
  return false
@@ -453,6 +472,7 @@ func safePackageProof(args []string) bool {
453
472
 
454
473
  func hasAnyArg(args []string, wants ...string) bool {
455
474
  for _, arg := range args {
475
+ arg = strings.Trim(arg, `"'`)
456
476
  for _, want := range wants {
457
477
  if arg == want {
458
478
  return true
@@ -464,6 +484,7 @@ func hasAnyArg(args []string, wants ...string) bool {
464
484
 
465
485
  func hasArgPrefix(args []string, prefix string) bool {
466
486
  for _, arg := range args {
487
+ arg = strings.Trim(arg, `"'`)
467
488
  if strings.HasPrefix(arg, prefix) {
468
489
  return true
469
490
  }
@@ -471,6 +492,27 @@ func hasArgPrefix(args []string, prefix string) bool {
471
492
  return false
472
493
  }
473
494
 
495
+ func hasShellOption(args []string, shortOptions string, longOptions ...string) bool {
496
+ for _, arg := range args {
497
+ arg = strings.Trim(arg, `"'`)
498
+ if arg == "--" {
499
+ return false
500
+ }
501
+ if strings.HasPrefix(arg, "--") {
502
+ for _, option := range longOptions {
503
+ if arg == option || strings.HasPrefix(arg, option+"=") {
504
+ return true
505
+ }
506
+ }
507
+ continue
508
+ }
509
+ if strings.HasPrefix(arg, "-") && strings.ContainsAny(strings.TrimPrefix(arg, "-"), shortOptions) {
510
+ return true
511
+ }
512
+ }
513
+ return false
514
+ }
515
+
474
516
  // hookReviewerReadonly keeps every DevRites leaf except slice-wright read only.
475
517
  // It always enforces declared DevRites runs. Undeclared runs keep the legacy
476
518
  // observe mode.
@@ -327,12 +327,12 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
327
327
  if pendingReceipt != "" {
328
328
  defer func() { _ = os.Remove(pendingReceipt) }()
329
329
  }
330
- if err := closeWindow(); err != nil {
331
- fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot close rejected slice window: %v\n", err)
330
+ if err := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
331
+ fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot persist abort receipt; retained window left open: %v\n", err)
332
332
  return 6
333
333
  }
334
- if err := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
335
- fmt.Fprintf(stderr, "reconcile: source baseline restored and window closed, but cannot persist abort receipt: %v\n", err)
334
+ if err := closeWindow(); err != nil {
335
+ fmt.Fprintf(stderr, "reconcile: source baseline restored and receipt persisted, but cannot close rejected slice window: %v\n", err)
336
336
  return 6
337
337
  }
338
338
  fmt.Fprintf(stdout, "reconcile: aborted rejected slice window for %s; restored %d source path(s); receipt %s.\n", slug, len(changed), receiptName)
@@ -962,25 +962,17 @@ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed [
962
962
  return fmt.Errorf("create restore directory: %w", err)
963
963
  }
964
964
  defer func() { _ = os.RemoveAll(materialized) }()
965
- index := filepath.Join(materialized, "index")
966
- restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
967
- if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", targetTree); err != nil {
968
- return err
969
- }
970
965
  materializedTree := filepath.Join(materialized, "tree")
971
966
  if err := os.MkdirAll(materializedTree, 0o700); err != nil {
972
967
  return fmt.Errorf("create restore tree: %w", err)
973
968
  }
974
- gitPrefix := filepath.ToSlash(materializedTree) + "/"
975
- if _, err := reconcileGitOutput(
976
- gitRoot,
977
- restoreEnv,
978
- "-c", "core.autocrlf=false",
979
- "checkout-index", "--all", "--force", "--prefix="+gitPrefix,
980
- ); err != nil {
981
- return err
982
- }
983
969
 
970
+ type restoreEntry struct {
971
+ path, target, parent, source string
972
+ mode os.FileMode
973
+ symlink, exists bool
974
+ }
975
+ entries := make([]restoreEntry, 0, len(changed))
984
976
  for _, changedPath := range changed {
985
977
  if changedPath == "." || changedPath == ".devrites" ||
986
978
  strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
@@ -992,39 +984,111 @@ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed [
992
984
  if !safepath.WithinResolved(targetParent, gitRoot) {
993
985
  return fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
994
986
  }
995
- source := filepath.Join(materializedTree, filepath.FromSlash(changedPath))
996
- sourceInfo, sourceErr := os.Lstat(source)
997
- if sourceErr != nil && !os.IsNotExist(sourceErr) {
998
- return fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
987
+ if targetInfo, targetErr := os.Lstat(target); targetErr == nil {
988
+ if targetInfo.IsDir() {
989
+ return fmt.Errorf("refusing to remove live directory at restore path %s", changedPath)
990
+ }
991
+ } else if !os.IsNotExist(targetErr) {
992
+ return fmt.Errorf("inspect restore target %s: %w", changedPath, targetErr)
999
993
  }
1000
- if err := os.RemoveAll(target); err != nil {
1001
- return fmt.Errorf("remove source path %s: %w", changedPath, err)
994
+
995
+ treeEntry, err := reconcileGitOutput(
996
+ gitRoot, env, "ls-tree", "-z", targetTree, "--", ":(top,literal)"+changedPath,
997
+ )
998
+ if err != nil {
999
+ return err
1002
1000
  }
1003
- if os.IsNotExist(sourceErr) {
1001
+ if len(treeEntry) == 0 {
1002
+ entries = append(entries, restoreEntry{path: changedPath, target: target, parent: targetParent})
1004
1003
  continue
1005
1004
  }
1006
- if err := os.MkdirAll(targetParent, 0o755); err != nil {
1007
- return fmt.Errorf("create restore parent for %s: %w", changedPath, err)
1005
+ record := strings.TrimSuffix(string(treeEntry), "\x00")
1006
+ if strings.Contains(record, "\x00") {
1007
+ return fmt.Errorf("ambiguous tree entry for %s", changedPath)
1008
+ }
1009
+ header, entryPath, ok := strings.Cut(record, "\t")
1010
+ fields := strings.Fields(header)
1011
+ if !ok || len(fields) != 3 || entryPath != changedPath {
1012
+ return fmt.Errorf("invalid tree entry for %s", changedPath)
1013
+ }
1014
+ if fields[0] == "160000" || fields[1] == "commit" {
1015
+ return fmt.Errorf("cannot safely restore changed submodule worktree %s", changedPath)
1016
+ }
1017
+ entry := restoreEntry{
1018
+ path: changedPath,
1019
+ target: target,
1020
+ parent: targetParent,
1021
+ source: filepath.Join(materializedTree, filepath.FromSlash(changedPath)),
1022
+ exists: true,
1023
+ symlink: fields[0] == "120000",
1024
+ }
1025
+ switch fields[0] {
1026
+ case "100644":
1027
+ entry.mode = 0o644
1028
+ case "100755":
1029
+ entry.mode = 0o755
1030
+ case "120000":
1031
+ default:
1032
+ return fmt.Errorf("unsupported tree mode %s for %s", fields[0], changedPath)
1008
1033
  }
1009
- switch {
1010
- case sourceInfo.Mode()&os.ModeSymlink != 0:
1011
- link, err := os.Readlink(source)
1012
- if err != nil {
1013
- return fmt.Errorf("read clean symlink %s: %w", changedPath, err)
1014
- }
1015
- if err := os.Symlink(link, target); err != nil {
1016
- return fmt.Errorf("restore symlink %s: %w", changedPath, err)
1017
- }
1018
- case sourceInfo.Mode().IsRegular():
1019
- data, err := os.ReadFile(source)
1034
+ if err := os.MkdirAll(filepath.Dir(entry.source), 0o700); err != nil {
1035
+ return fmt.Errorf("create materialized parent for %s: %w", changedPath, err)
1036
+ }
1037
+ source, err := os.OpenFile(entry.source, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
1038
+ if err != nil {
1039
+ return fmt.Errorf("create materialized source %s: %w", changedPath, err)
1040
+ }
1041
+ writeErr := runGitCommandToWriter(gitRoot, env, source, "cat-file", "blob", fields[2])
1042
+ closeErr := source.Close()
1043
+ if writeErr != nil {
1044
+ return fmt.Errorf("materialize source %s: %w", changedPath, writeErr)
1045
+ }
1046
+ if closeErr != nil {
1047
+ return fmt.Errorf("close materialized source %s: %w", changedPath, closeErr)
1048
+ }
1049
+ entries = append(entries, entry)
1050
+ }
1051
+
1052
+ for _, entry := range entries {
1053
+ if err := os.RemoveAll(entry.target); err != nil {
1054
+ return fmt.Errorf("remove source path %s: %w", entry.path, err)
1055
+ }
1056
+ if !entry.exists {
1057
+ continue
1058
+ }
1059
+ if err := os.MkdirAll(entry.parent, 0o755); err != nil {
1060
+ return fmt.Errorf("create restore parent for %s: %w", entry.path, err)
1061
+ }
1062
+ if entry.symlink {
1063
+ link, err := os.ReadFile(entry.source)
1020
1064
  if err != nil {
1021
- return fmt.Errorf("read clean file %s: %w", changedPath, err)
1065
+ return fmt.Errorf("read clean symlink %s: %w", entry.path, err)
1022
1066
  }
1023
- if err := os.WriteFile(target, data, sourceInfo.Mode().Perm()); err != nil {
1024
- return fmt.Errorf("restore file %s: %w", changedPath, err)
1067
+ if err := os.Symlink(string(link), entry.target); err != nil {
1068
+ return fmt.Errorf("restore symlink %s: %w", entry.path, err)
1025
1069
  }
1026
- default:
1027
- return fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
1070
+ continue
1071
+ }
1072
+ source, err := os.Open(entry.source)
1073
+ if err != nil {
1074
+ return fmt.Errorf("open clean file %s: %w", entry.path, err)
1075
+ }
1076
+ target, err := os.OpenFile(entry.target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, entry.mode)
1077
+ if err != nil {
1078
+ _ = source.Close()
1079
+ return fmt.Errorf("create restored file %s: %w", entry.path, err)
1080
+ }
1081
+ _, copyErr := io.Copy(target, source)
1082
+ sourceCloseErr := source.Close()
1083
+ targetCloseErr := target.Close()
1084
+ if copyErr != nil {
1085
+ return fmt.Errorf("restore file %s: %w", entry.path, copyErr)
1086
+ }
1087
+ if sourceCloseErr != nil {
1088
+ return fmt.Errorf("close clean file %s: %w", entry.path, sourceCloseErr)
1089
+ }
1090
+ if targetCloseErr != nil {
1091
+ return fmt.Errorf("close restored file %s: %w", entry.path, targetCloseErr)
1028
1092
  }
1029
1093
  }
1030
1094
  return nil
@@ -1193,6 +1257,9 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
1193
1257
  return "", fmt.Errorf("exclude %s from worktree snapshot: %w", rel, err)
1194
1258
  }
1195
1259
  }
1260
+ if err := rewriteIndexWithRawWorktreeBytes(gitRoot, env); err != nil {
1261
+ return "", err
1262
+ }
1196
1263
 
1197
1264
  out, err := runGitCommand(gitRoot, env, "write-tree")
1198
1265
  if err != nil {
@@ -1205,6 +1272,83 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
1205
1272
  return tree, nil
1206
1273
  }
1207
1274
 
1275
+ func rewriteIndexWithRawWorktreeBytes(gitRoot string, env []string) error {
1276
+ out, err := runGitCommand(gitRoot, env, "ls-files", "--stage", "-z")
1277
+ if err != nil {
1278
+ return fmt.Errorf("list worktree snapshot entries: %w", err)
1279
+ }
1280
+ type rawEntry struct {
1281
+ mode, path string
1282
+ }
1283
+ var batch, individual []rawEntry
1284
+ for _, record := range strings.Split(string(out), "\x00") {
1285
+ if record == "" {
1286
+ continue
1287
+ }
1288
+ header, filename, ok := strings.Cut(record, "\t")
1289
+ fields := strings.Fields(header)
1290
+ if !ok || len(fields) != 3 || fields[2] != "0" {
1291
+ return fmt.Errorf("invalid worktree snapshot index entry")
1292
+ }
1293
+ if fields[0] != "100644" && fields[0] != "100755" {
1294
+ continue
1295
+ }
1296
+ entry := rawEntry{mode: fields[0], path: filename}
1297
+ if strings.ContainsRune(filename, '\n') {
1298
+ individual = append(individual, entry)
1299
+ } else {
1300
+ batch = append(batch, entry)
1301
+ }
1302
+ }
1303
+
1304
+ var indexInfo strings.Builder
1305
+ if len(batch) > 0 {
1306
+ var paths strings.Builder
1307
+ for _, entry := range batch {
1308
+ paths.WriteString(entry.path)
1309
+ paths.WriteByte('\n')
1310
+ }
1311
+ hashes, err := runGitCommandInput(
1312
+ gitRoot, env, []byte(paths.String()),
1313
+ "hash-object", "-w", "--no-filters", "--stdin-paths",
1314
+ )
1315
+ if err != nil {
1316
+ return fmt.Errorf("hash raw worktree files: %w", err)
1317
+ }
1318
+ objectIDs := strings.Fields(string(hashes))
1319
+ if len(objectIDs) != len(batch) {
1320
+ return fmt.Errorf("hash raw worktree files: got %d object ids for %d paths", len(objectIDs), len(batch))
1321
+ }
1322
+ for i, entry := range batch {
1323
+ if !reconcileObjectID.MatchString(objectIDs[i]) {
1324
+ return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
1325
+ }
1326
+ fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectIDs[i], entry.path)
1327
+ }
1328
+ }
1329
+ for _, entry := range individual {
1330
+ hash, err := runGitCommand(gitRoot, env, "hash-object", "-w", "--no-filters", "--", entry.path)
1331
+ if err != nil {
1332
+ return fmt.Errorf("hash raw worktree file %s: %w", entry.path, err)
1333
+ }
1334
+ objectID := strings.TrimSpace(string(hash))
1335
+ if !reconcileObjectID.MatchString(objectID) {
1336
+ return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
1337
+ }
1338
+ fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectID, entry.path)
1339
+ }
1340
+ if indexInfo.Len() == 0 {
1341
+ return nil
1342
+ }
1343
+ if _, err := runGitCommandInput(
1344
+ gitRoot, env, []byte(indexInfo.String()),
1345
+ "update-index", "-z", "--index-info",
1346
+ ); err != nil {
1347
+ return fmt.Errorf("record raw worktree files: %w", err)
1348
+ }
1349
+ return nil
1350
+ }
1351
+
1208
1352
  func reconcileGitEnv(gitRoot, objectDir string) ([]string, error) {
1209
1353
  out, err := runGitCommand(gitRoot, nil, "rev-parse", "--git-common-dir")
1210
1354
  if err != nil {
@@ -392,6 +392,80 @@ func TestReconcileAbortRestoresOriginalSourceAndClosesWindow(t *testing.T) {
392
392
  }
393
393
  }
394
394
 
395
+ func TestReconcileAbortRestoresOriginalCRLFBytes(t *testing.T) {
396
+ gitRoot := newGitRepo(t)
397
+ root := workspace(t, "feat")
398
+ writeWrightAllowlist(t, root, "feat", "seed.go")
399
+ if out, err := exec.Command("git", "-C", gitRoot, "config", "core.autocrlf", "true").CombinedOutput(); err != nil {
400
+ t.Fatalf("configure core.autocrlf: %v\n%s", err, out)
401
+ }
402
+ baseline := []byte("package main\r\n\r\nfunc userWork() {}\r\n")
403
+ if err := os.WriteFile(filepath.Join(gitRoot, "seed.go"), baseline, 0o644); err != nil {
404
+ t.Fatal(err)
405
+ }
406
+
407
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
408
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
409
+ }
410
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc rejectedWriterChange() {}\n")
411
+
412
+ if code, out := runReconcile(t, root, "abort", "feat"); code != 0 {
413
+ t.Fatalf("abort = %d, want 0\n%s", code, out)
414
+ }
415
+ restored, err := os.ReadFile(filepath.Join(gitRoot, "seed.go"))
416
+ if err != nil {
417
+ t.Fatal(err)
418
+ }
419
+ if !bytes.Equal(restored, baseline) {
420
+ t.Fatalf("restored bytes = %q, want original bytes %q", restored, baseline)
421
+ }
422
+ }
423
+
424
+ func TestReconcileAbortPreservesChangedSubmoduleWorktree(t *testing.T) {
425
+ submoduleOrigin := newGitRepo(t)
426
+ gitRoot := newGitRepo(t)
427
+ git := func(dir string, args ...string) string {
428
+ t.Helper()
429
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
430
+ out, err := cmd.CombinedOutput()
431
+ if err != nil {
432
+ t.Fatalf("git %v: %v\n%s", args, err, out)
433
+ }
434
+ return strings.TrimSpace(string(out))
435
+ }
436
+ git(gitRoot, "-c", "protocol.file.allow=always", "submodule", "add", "-q", submoduleOrigin, "sub")
437
+ commitAll(t, gitRoot, "add submodule")
438
+
439
+ root := workspace(t, "feat")
440
+ writeWrightAllowlist(t, root, "feat")
441
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
442
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
443
+ }
444
+
445
+ writeFile(t, filepath.Join(submoduleOrigin, "seed.go"), "package changed\n")
446
+ commitAll(t, submoduleOrigin, "advance submodule")
447
+ advanced := git(submoduleOrigin, "rev-parse", "HEAD")
448
+ git(filepath.Join(gitRoot, "sub"), "-c", "protocol.file.allow=always", "fetch", "-q", "origin")
449
+ git(filepath.Join(gitRoot, "sub"), "checkout", "-q", advanced)
450
+ local := []byte("uncommitted submodule work\n")
451
+ if err := os.WriteFile(filepath.Join(gitRoot, "sub", "local.txt"), local, 0o644); err != nil {
452
+ t.Fatal(err)
453
+ }
454
+
455
+ code, out := runReconcile(t, root, "abort", "feat")
456
+ if code != 6 {
457
+ t.Fatalf("abort = %d, want fail-closed 6 for a changed submodule\n%s", code, out)
458
+ }
459
+ if restored, err := os.ReadFile(filepath.Join(gitRoot, "sub", "local.txt")); err != nil {
460
+ t.Fatalf("abort removed the live submodule worktree: %v\n%s", err, out)
461
+ } else if !bytes.Equal(restored, local) {
462
+ t.Fatalf("abort changed uncommitted submodule bytes: %q", restored)
463
+ }
464
+ if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) {
465
+ t.Fatal("failed abort removed its retained baseline marker")
466
+ }
467
+ }
468
+
395
469
  func TestReconcileAbortFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
396
470
  gitRoot := newGitRepo(t)
397
471
  root := workspace(t, "feat")
@@ -5,6 +5,7 @@ import (
5
5
  "context"
6
6
  "errors"
7
7
  "fmt"
8
+ "io"
8
9
  "os"
9
10
  "os/exec"
10
11
  "path/filepath"
@@ -57,11 +58,27 @@ func (e *gitCommandError) Error() string {
57
58
  func (e *gitCommandError) Unwrap() error { return e.err }
58
59
 
59
60
  func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
61
+ return runGitCommandIO(dir, env, nil, nil, args...)
62
+ }
63
+
64
+ func runGitCommandInput(dir string, env []string, input []byte, args ...string) ([]byte, error) {
65
+ return runGitCommandIO(dir, env, input, nil, args...)
66
+ }
67
+
68
+ func runGitCommandToWriter(dir string, env []string, stdout io.Writer, args ...string) error {
69
+ _, err := runGitCommandIO(dir, env, nil, stdout, args...)
70
+ return err
71
+ }
72
+
73
+ func runGitCommandIO(dir string, env []string, input []byte, stdout io.Writer, args ...string) ([]byte, error) {
60
74
  ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout)
61
75
  defer cancel()
62
76
  commandArgs := append([]string{"-C", dir}, args...)
63
77
  cmd := exec.CommandContext(ctx, "git", commandArgs...)
64
78
  cmd.WaitDelay = 2 * time.Second
79
+ if input != nil {
80
+ cmd.Stdin = bytes.NewReader(input)
81
+ }
65
82
  if env == nil {
66
83
  env = os.Environ()
67
84
  } else {
@@ -76,7 +93,11 @@ func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
76
93
  "LC_ALL=C",
77
94
  )
78
95
  var output cappedGitOutput
79
- cmd.Stdout = &output
96
+ if stdout == nil {
97
+ cmd.Stdout = &output
98
+ } else {
99
+ cmd.Stdout = stdout
100
+ }
80
101
  cmd.Stderr = &output
81
102
  err := cmd.Run()
82
103
  if err == nil {
@@ -293,6 +293,33 @@ func TestHookReviewerReadonlyAllowsSafeBash(t *testing.T) {
293
293
  }
294
294
  }
295
295
 
296
+ func TestHookReviewerReadonlyRejectsWriteCapableInspectionFlags(t *testing.T) {
297
+ root := newWorkspace(t)
298
+ env := []string{
299
+ "DEVRITES_AGENT_RUN=1",
300
+ "DEVRITES_ACTIVE_AGENT=devrites-code-reviewer",
301
+ }
302
+ for _, command := range []string{
303
+ "sed --in-place=.bak s/a/b/ src/app.go",
304
+ "sort -o src/app.go input.txt",
305
+ "find . -fls src/index.txt",
306
+ "git diff --output=src/diff.txt",
307
+ } {
308
+ in := hookPayload(t, map[string]any{
309
+ "tool_name": "Bash",
310
+ "tool_input": map[string]any{"command": command},
311
+ })
312
+ out, errOut, code := runDevritesIO(t, root, in, env,
313
+ "hook", "reviewer-readonly", "--harness=codex")
314
+ if code != 0 {
315
+ t.Fatalf("%q exit=%d stderr=%q", command, code, errOut)
316
+ }
317
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
318
+ t.Errorf("write-capable inspection command was not denied: %q", command)
319
+ }
320
+ }
321
+ }
322
+
296
323
  func TestHookReviewerReadonlyNonBashIsSilent(t *testing.T) {
297
324
  root := newWorkspace(t)
298
325
  in := `{"tool_name":"Read","tool_input":{"file_path":"secret.txt"}}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.23",
3
+ "version": "3.2.24",
4
4
  "description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",