devrites 3.2.23 → 3.2.25
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 +12 -0
- package/README.md +1 -1
- package/engine/hooks.go +49 -7
- package/engine/internal/lib/reconcile.go +187 -43
- package/engine/internal/lib/reconcile_test.go +74 -0
- package/engine/internal/lib/util.go +22 -1
- package/engine/tests/hook_test.go +27 -0
- package/package.json +6 -2
- package/scripts/build-host-artifacts.sh +2 -2
- package/scripts/build-release-tarball.sh +4 -4
- package/scripts/check-generated-skill-budget.mjs +2 -1
- package/scripts/check-instruction-size-baseline.mjs +2 -1
- package/scripts/check-reference-governance.mjs +2 -1
- package/scripts/pin.sh +70 -24
- package/scripts/run-tests.mjs +2 -1
- package/scripts/scan-pack-security.py +1 -1
- package/scripts/skill-pruning-audit.mjs +2 -1
- package/scripts/skills-inventory.mjs +2 -1
- package/scripts/validate-workflow-security.py +68 -9
- package/scripts/validate.sh +11 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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.25](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.24...v3.2.25) (2026-07-28)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **devrites:** close review safety gaps ([178a43e](https://github.com/ViktorsBaikers/DevRites/commit/178a43e5a25a7b0825c0e16aa76c402e6b48a3aa))
|
|
10
|
+
|
|
11
|
+
## [3.2.24](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.23...v3.2.24) (2026-07-28)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **devrites:** close review safety gaps ([33e736a](https://github.com/ViktorsBaikers/DevRites/commit/33e736a3d9d69fd166827235ed1ce2310aeca75a))
|
|
16
|
+
|
|
5
17
|
## [3.2.23](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.22...v3.2.23) (2026-07-28)
|
|
6
18
|
|
|
7
19
|
### 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.
|
|
28
|
+
**Status:** [`v3.2.25`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.25): 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", "
|
|
393
|
-
"grep", "
|
|
394
|
-
"readlink", "realpath", "basename", "dirname", "cmp", "
|
|
395
|
-
"
|
|
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 !
|
|
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 !
|
|
418
|
+
return !hasShellOption(args, "i", "--in-place", "--inplace")
|
|
402
419
|
case "git":
|
|
403
|
-
return len(args) > 0 &&
|
|
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 :=
|
|
331
|
-
fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot
|
|
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 :=
|
|
335
|
-
fmt.Fprintf(stderr, "reconcile: source baseline restored and
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
|
1001
|
+
if len(treeEntry) == 0 {
|
|
1002
|
+
entries = append(entries, restoreEntry{path: changedPath, target: target, parent: targetParent})
|
|
1004
1003
|
continue
|
|
1005
1004
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
|
1065
|
+
return fmt.Errorf("read clean symlink %s: %w", entry.path, err)
|
|
1022
1066
|
}
|
|
1023
|
-
if err := os.
|
|
1024
|
-
return fmt.Errorf("restore
|
|
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
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.2.25",
|
|
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",
|
|
@@ -32,10 +32,14 @@
|
|
|
32
32
|
"engine/",
|
|
33
33
|
"pack/",
|
|
34
34
|
"scripts/",
|
|
35
|
+
"!scripts/__pycache__/",
|
|
36
|
+
"!scripts/*.pyc",
|
|
37
|
+
"!scripts/.cache/",
|
|
35
38
|
"install.sh",
|
|
36
39
|
"uninstall.sh",
|
|
37
40
|
"update.sh",
|
|
38
41
|
"docs/",
|
|
42
|
+
"!docs/internal/",
|
|
39
43
|
"README.md",
|
|
40
44
|
"LICENSE",
|
|
41
45
|
"SECURITY.md",
|
|
@@ -47,7 +51,7 @@
|
|
|
47
51
|
},
|
|
48
52
|
"scripts": {
|
|
49
53
|
"prepare": "husky || true",
|
|
50
|
-
"prepack": "bash scripts/build-host-artifacts.sh
|
|
54
|
+
"prepack": "bash scripts/build-host-artifacts.sh",
|
|
51
55
|
"commitlint": "commitlint --edit",
|
|
52
56
|
"validate": "bash scripts/validate.sh",
|
|
53
57
|
"test": "node scripts/run-tests.mjs",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Default output is pack/generated/ so npm pack can ship prebuilt surfaces.
|
|
6
6
|
# Tests may set DEVRITES_HOST_ARTIFACT_DIR to a temporary directory.
|
|
7
|
-
set -
|
|
7
|
+
set -euo pipefail
|
|
8
8
|
|
|
9
9
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
10
10
|
PACK_SRC="$ROOT/pack/.claude"
|
|
@@ -13,7 +13,7 @@ OUT_ROOT="${DEVRITES_HOST_ARTIFACT_DIR:-$ROOT/pack/generated}"
|
|
|
13
13
|
[ -d "$PACK_SRC/skills" ] || { echo "build-host-artifacts: missing $PACK_SRC/skills" >&2; exit 1; }
|
|
14
14
|
[ -d "$PACK_SRC/agents" ] || { echo "build-host-artifacts: missing $PACK_SRC/agents" >&2; exit 1; }
|
|
15
15
|
|
|
16
|
-
TMP_GEN_DIR="$(mktemp -d
|
|
16
|
+
TMP_GEN_DIR="$(mktemp -d)"
|
|
17
17
|
cleanup() { rm -rf "$TMP_GEN_DIR"; }
|
|
18
18
|
trap cleanup EXIT
|
|
19
19
|
|
|
@@ -57,14 +57,14 @@ PAYLOAD=(
|
|
|
57
57
|
|
|
58
58
|
for item in "${PAYLOAD[@]}"; do
|
|
59
59
|
if [[ -e "$item" ]]; then
|
|
60
|
-
if
|
|
60
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
61
61
|
while IFS= read -r -d '' path; do
|
|
62
|
-
[[ -e "$path" ]] || continue
|
|
62
|
+
[[ -e "$path" || -L "$path" ]] || continue
|
|
63
63
|
if [[ "$path" == engine/testdata/golden/* ]]; then
|
|
64
64
|
continue
|
|
65
65
|
fi
|
|
66
66
|
mkdir -p "$STAGE/$(dirname "$path")"
|
|
67
|
-
cp "$path" "$STAGE/$path"
|
|
67
|
+
cp -P "$path" "$STAGE/$path"
|
|
68
68
|
done < <(git ls-files -z -- "$item")
|
|
69
69
|
else
|
|
70
70
|
cp -R "$item" "$STAGE/"
|
|
@@ -73,7 +73,7 @@ for item in "${PAYLOAD[@]}"; do
|
|
|
73
73
|
done
|
|
74
74
|
|
|
75
75
|
# Include the same prebuilt host artifacts as the npm package.
|
|
76
|
-
DEVRITES_HOST_ARTIFACT_DIR="$STAGE/pack/generated" bash "$
|
|
76
|
+
DEVRITES_HOST_ARTIFACT_DIR="$STAGE/pack/generated" bash "$STAGE/scripts/build-host-artifacts.sh" >/dev/null
|
|
77
77
|
|
|
78
78
|
# Remove development files copied with the payload.
|
|
79
79
|
rm -rf "$STAGE/docs/internal" "$STAGE/scripts/.cache" 2>/dev/null || true
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Guard generated host skill payloads against accidental context bloat.
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const root = new URL('..', import.meta.url)
|
|
7
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const base = process.argv[2] || join(root, 'pack', '.claude', 'skills');
|
|
8
9
|
const totalLimit = Number(process.env.DEVRITES_SKILL_TOTAL_BUDGET || 900_000);
|
|
9
10
|
const fileLimit = Number(process.env.DEVRITES_SKILL_FILE_BUDGET || 64_000);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Track canonical instruction files individually and skills as a group.
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const defaultRoot = new URL('..', import.meta.url)
|
|
7
|
+
const defaultRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const argv = process.argv.slice(2);
|
|
8
9
|
function option(name, fallback) {
|
|
9
10
|
const i = argv.indexOf(name);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Ensure supporting skill references are reachable or explicitly time-bounded.
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const defaultRoot = new URL('..', import.meta.url)
|
|
7
|
+
const defaultRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const argv = process.argv.slice(2);
|
|
8
9
|
function option(name, fallback) {
|
|
9
10
|
const i = argv.indexOf(name);
|
package/scripts/pin.sh
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
# - Manifest-managed: pinned wrappers are recorded in .claude/devrites.manifest
|
|
30
30
|
# so the standard uninstall.sh cleans them up automatically.
|
|
31
31
|
|
|
32
|
-
set -
|
|
32
|
+
set -euo pipefail
|
|
33
33
|
|
|
34
34
|
# ---- locate install-lib + load helpers ----------------------------------
|
|
35
35
|
SELF_DIR="$( cd "$(dirname "$0")" && pwd -P )"
|
|
@@ -77,6 +77,16 @@ MF="$TARGET/$DR_MANIFEST_NAME"
|
|
|
77
77
|
[ -f "$MF" ] || dr_die "no manifest at $MF: run install.sh first?"
|
|
78
78
|
|
|
79
79
|
# ---- helpers -------------------------------------------------------------
|
|
80
|
+
PIN_TMP=""
|
|
81
|
+
cleanup_pin_tmp() {
|
|
82
|
+
[ -z "$PIN_TMP" ] || rm -rf "$PIN_TMP"
|
|
83
|
+
}
|
|
84
|
+
trap cleanup_pin_tmp EXIT
|
|
85
|
+
|
|
86
|
+
path_exists() {
|
|
87
|
+
[ -e "$1" ] || [ -L "$1" ]
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
valid_alias_name() {
|
|
81
91
|
# lowercase ASCII, digits, hyphens. No /, ., spaces. Not "rite" or "rite-*".
|
|
82
92
|
case "$1" in
|
|
@@ -105,6 +115,20 @@ is_pinned_alias_file() {
|
|
|
105
115
|
grep -q 'description: Alias of DevRites /' "$1"
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
preflight_alias_destination() {
|
|
119
|
+
_dir="$1"
|
|
120
|
+
_file="$2"
|
|
121
|
+
if [ -L "$_dir" ] || { path_exists "$_dir" && [ ! -d "$_dir" ]; }; then
|
|
122
|
+
dr_die "$_dir exists and is not a managed alias directory: refusing to overwrite"
|
|
123
|
+
fi
|
|
124
|
+
if path_exists "$_file"; then
|
|
125
|
+
[ ! -L "$_file" ] && is_pinned_alias_file "$_file" \
|
|
126
|
+
|| dr_die "$_file exists and is NOT a pinned alias: refusing to overwrite"
|
|
127
|
+
elif [ -d "$_dir" ]; then
|
|
128
|
+
dr_die "$_dir exists without a pinned alias: refusing to overwrite"
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
# ---- subcommands ---------------------------------------------------------
|
|
109
133
|
do_add() {
|
|
110
134
|
valid_alias_name "$ALIAS" || dr_die "invalid alias name '$ALIAS' (lowercase / digits / hyphens; not 'rite' or 'rite-*')"
|
|
@@ -118,34 +142,47 @@ do_add() {
|
|
|
118
142
|
CODEX_ALIAS_FILE="$CODEX_ALIAS_DIR/SKILL.md"
|
|
119
143
|
CODEX_ALIAS_REL=".agents/skills/$ALIAS/SKILL.md"
|
|
120
144
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else
|
|
125
|
-
dr_die "$ALIAS_FILE exists and is NOT a pinned alias: refusing to overwrite"
|
|
126
|
-
fi
|
|
145
|
+
preflight_alias_destination "$ALIAS_DIR" "$ALIAS_FILE"
|
|
146
|
+
if path_exists "$ALIAS_FILE"; then
|
|
147
|
+
dr_warn "already pinned: /$ALIAS: overwriting"
|
|
127
148
|
fi
|
|
128
|
-
if [ -d "$CODEX_SKILLS_DIR" ]
|
|
129
|
-
|
|
149
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
150
|
+
preflight_alias_destination "$CODEX_ALIAS_DIR" "$CODEX_ALIAS_FILE"
|
|
151
|
+
if path_exists "$CODEX_ALIAS_FILE"; then
|
|
130
152
|
dr_warn "already pinned for Codex: /$ALIAS: overwriting"
|
|
131
|
-
else
|
|
132
|
-
dr_die "$CODEX_ALIAS_FILE exists and is NOT a pinned alias: refusing to overwrite"
|
|
133
153
|
fi
|
|
134
154
|
fi
|
|
135
155
|
|
|
156
|
+
PIN_TMP="$(mktemp -d "$SKILLS_DIR/.devrites-pin.XXXXXX")"
|
|
157
|
+
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$PIN_TMP/claude"
|
|
158
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
159
|
+
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$PIN_TMP/codex"
|
|
160
|
+
fi
|
|
161
|
+
cp -p "$MF" "$PIN_TMP/manifest"
|
|
162
|
+
if ! dr_manifest_contains "$PIN_TMP/manifest" "$ALIAS_REL"; then
|
|
163
|
+
printf '%s\n' "$ALIAS_REL" >> "$PIN_TMP/manifest"
|
|
164
|
+
fi
|
|
165
|
+
if [ -d "$CODEX_SKILLS_DIR" ] && ! dr_manifest_contains "$PIN_TMP/manifest" "$CODEX_ALIAS_REL"; then
|
|
166
|
+
printf '%s\n' "$CODEX_ALIAS_REL" >> "$PIN_TMP/manifest"
|
|
167
|
+
fi
|
|
168
|
+
|
|
136
169
|
mkdir -p "$ALIAS_DIR"
|
|
137
|
-
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$ALIAS_FILE"
|
|
138
170
|
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
139
171
|
mkdir -p "$CODEX_ALIAS_DIR"
|
|
140
|
-
dr_gen_alias_wrapper "$ALIAS" "$DEST" "$CODEX_ALIAS_FILE"
|
|
141
172
|
fi
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
[ -w "$ALIAS_DIR" ] || dr_die "$ALIAS_DIR is not writable"
|
|
174
|
+
[ -w "$(dirname "$MF")" ] || dr_die "$(dirname "$MF") is not writable"
|
|
175
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
176
|
+
[ -w "$CODEX_ALIAS_DIR" ] || dr_die "$CODEX_ALIAS_DIR is not writable"
|
|
145
177
|
fi
|
|
146
|
-
|
|
147
|
-
|
|
178
|
+
|
|
179
|
+
mv "$PIN_TMP/claude" "$ALIAS_FILE"
|
|
180
|
+
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
181
|
+
mv "$PIN_TMP/codex" "$CODEX_ALIAS_FILE"
|
|
148
182
|
fi
|
|
183
|
+
mv "$PIN_TMP/manifest" "$MF"
|
|
184
|
+
rm -rf "$PIN_TMP"
|
|
185
|
+
PIN_TMP=""
|
|
149
186
|
|
|
150
187
|
if [ -d "$CODEX_SKILLS_DIR" ]; then
|
|
151
188
|
dr_ok "pinned: /$ALIAS → /$DEST ($ALIAS_FILE, $CODEX_ALIAS_FILE)"
|
|
@@ -166,17 +203,24 @@ do_remove() {
|
|
|
166
203
|
[ -f "$ALIAS_FILE" ] || dr_die "no pinned alias at $ALIAS_FILE"
|
|
167
204
|
is_pinned_alias "$ALIAS" || dr_die "$ALIAS_FILE exists but is not a pinned alias: refusing to remove"
|
|
168
205
|
|
|
206
|
+
if path_exists "$CODEX_ALIAS_FILE"; then
|
|
207
|
+
is_pinned_alias_file "$CODEX_ALIAS_FILE" || dr_die "$CODEX_ALIAS_FILE exists but is not a pinned alias: refusing to remove"
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
PIN_TMP="$(mktemp -d "$SKILLS_DIR/.devrites-pin.XXXXXX")"
|
|
211
|
+
awk -v claude="$ALIAS_REL" -v codex="$CODEX_ALIAS_REL" \
|
|
212
|
+
'$0 != claude && $0 != codex' "$MF" > "$PIN_TMP/manifest"
|
|
213
|
+
|
|
169
214
|
rm -f "$ALIAS_FILE"
|
|
170
215
|
rmdir "$ALIAS_DIR" 2>/dev/null || true
|
|
171
|
-
if
|
|
172
|
-
is_pinned_alias_file "$CODEX_ALIAS_FILE" || dr_die "$CODEX_ALIAS_FILE exists but is not a pinned alias: refusing to remove"
|
|
216
|
+
if path_exists "$CODEX_ALIAS_FILE"; then
|
|
173
217
|
rm -f "$CODEX_ALIAS_FILE"
|
|
174
218
|
rmdir "$CODEX_ALIAS_DIR" 2>/dev/null || true
|
|
175
219
|
fi
|
|
176
220
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
221
|
+
mv "$PIN_TMP/manifest" "$MF"
|
|
222
|
+
rm -rf "$PIN_TMP"
|
|
223
|
+
PIN_TMP=""
|
|
180
224
|
|
|
181
225
|
dr_ok "unpinned: /$ALIAS"
|
|
182
226
|
}
|
|
@@ -192,7 +236,9 @@ do_list() {
|
|
|
192
236
|
found=1
|
|
193
237
|
fi
|
|
194
238
|
done
|
|
195
|
-
[ "$found" -eq 0 ]
|
|
239
|
+
if [ "$found" -eq 0 ]; then
|
|
240
|
+
dr_say "(no pinned aliases at $TARGET)"
|
|
241
|
+
fi
|
|
196
242
|
}
|
|
197
243
|
|
|
198
244
|
case "$SUBCMD" in
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
|
|
7
|
-
const root = new URL('..', import.meta.url)
|
|
8
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
8
9
|
const testsDir = join(root, 'tests');
|
|
9
10
|
const args = process.argv.slice(2);
|
|
10
11
|
let jobs = Math.max(1, Math.min(5, Math.floor(Number(process.env.DEVRITES_TEST_JOBS || 4)) || 4));
|
|
@@ -151,7 +151,7 @@ def _file_suppressions(text):
|
|
|
151
151
|
|
|
152
152
|
# --- driver ---------------------------------------------------------------
|
|
153
153
|
|
|
154
|
-
TEXT_EXTS = {".md", ".sh", ".json", ".txt", ".py", ".js", ".yaml", ".yml", ""}
|
|
154
|
+
TEXT_EXTS = {".md", ".sh", ".json", ".toml", ".txt", ".py", ".js", ".yaml", ".yml", ""}
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
def iter_files(paths):
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Advisory pruning audit plus blocking ordered-step completion contracts.
|
|
3
3
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const root = new URL('..', import.meta.url)
|
|
7
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const skillsArg = process.argv.indexOf('--skills-dir');
|
|
8
9
|
const skillsDir = skillsArg >= 0 ? process.argv[skillsArg + 1] : join(root, 'pack', '.claude', 'skills');
|
|
9
10
|
const quiet = process.argv.includes('--quiet');
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Verify the authored DevRites skill inventory and documentation counts.
|
|
3
3
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
|
-
const root = new URL('..', import.meta.url)
|
|
7
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
7
8
|
const skillsDir = join(root, 'pack', '.claude', 'skills');
|
|
8
9
|
const docsSkills = join(root, 'docs', 'skills.md');
|
|
9
10
|
const docsCommandMap = join(root, 'docs', 'command-map.md');
|
|
@@ -20,25 +20,82 @@ import re
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
23
|
-
USES_RE = re.compile(r"^\s*-?\s*uses
|
|
23
|
+
USES_RE = re.compile(r"^\s*-?\s*uses\s*:\s*([^\s#]+)")
|
|
24
24
|
DEPENDABOT_ONLY_RE = re.compile(
|
|
25
|
-
r"^\s*if
|
|
25
|
+
r"^\s*if\s*:\s*(?:\$\{\{\s*)?"
|
|
26
26
|
r"(?:github\.actor|github\.event\.pull_request\.user\.login)\s*==\s*"
|
|
27
27
|
r"['\"]dependabot\[bot\]['\"]",
|
|
28
28
|
re.MULTILINE,
|
|
29
29
|
)
|
|
30
30
|
UNQUOTED_NAME_COLON_RE = re.compile(r"^\s*(?:-\s*)?name:\s+[^'\"].*:\s+\S")
|
|
31
|
-
RUN_RE = re.compile(r"^(\s*)(?:-\s*)?run
|
|
31
|
+
RUN_RE = re.compile(r"^(\s*)(?:-\s*)?run\s*:\s*(.*)$")
|
|
32
32
|
DISPATCH_EXPRESSION_RE = re.compile(r"\$\{\{[^}]*\binputs\b", re.IGNORECASE)
|
|
33
|
+
KEY_RE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def jobs_without_permissions(lines):
|
|
37
|
+
"""Return job IDs missing direct permissions, or None with a global scope."""
|
|
38
|
+
for line in lines:
|
|
39
|
+
match = KEY_RE.match(line)
|
|
40
|
+
if match and not match.group(1) and match.group(2) == "permissions":
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
jobs_line = None
|
|
44
|
+
for i, line in enumerate(lines):
|
|
45
|
+
match = KEY_RE.match(line)
|
|
46
|
+
if match and not match.group(1) and match.group(2) == "jobs":
|
|
47
|
+
jobs_line = i
|
|
48
|
+
break
|
|
49
|
+
if jobs_line is None:
|
|
50
|
+
return ["<workflow>"]
|
|
51
|
+
|
|
52
|
+
job_indent = None
|
|
53
|
+
job_starts = []
|
|
54
|
+
for i in range(jobs_line + 1, len(lines)):
|
|
55
|
+
line = lines[i]
|
|
56
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
match = KEY_RE.match(line)
|
|
59
|
+
if not match:
|
|
60
|
+
continue
|
|
61
|
+
indent = len(match.group(1))
|
|
62
|
+
if indent == 0:
|
|
63
|
+
break
|
|
64
|
+
if job_indent is None:
|
|
65
|
+
job_indent = indent
|
|
66
|
+
if indent == job_indent:
|
|
67
|
+
job_starts.append((i, match.group(2)))
|
|
68
|
+
if not job_starts:
|
|
69
|
+
return ["<workflow>"]
|
|
70
|
+
|
|
71
|
+
missing = []
|
|
72
|
+
for position, (start, job_id) in enumerate(job_starts):
|
|
73
|
+
end = job_starts[position + 1][0] if position + 1 < len(job_starts) else len(lines)
|
|
74
|
+
property_indents = []
|
|
75
|
+
properties = []
|
|
76
|
+
for line in lines[start + 1:end]:
|
|
77
|
+
match = KEY_RE.match(line)
|
|
78
|
+
if not match:
|
|
79
|
+
continue
|
|
80
|
+
indent = len(match.group(1))
|
|
81
|
+
if indent > job_indent:
|
|
82
|
+
property_indents.append(indent)
|
|
83
|
+
properties.append((indent, match.group(2)))
|
|
84
|
+
direct_indent = min(property_indents) if property_indents else None
|
|
85
|
+
if direct_indent is None or not any(
|
|
86
|
+
indent == direct_indent and key == "permissions"
|
|
87
|
+
for indent, key in properties):
|
|
88
|
+
missing.append(job_id)
|
|
89
|
+
return missing
|
|
33
90
|
|
|
34
91
|
|
|
35
92
|
def safe_dependabot_target(text):
|
|
36
|
-
if re.search(r"^\s*-?\s*uses
|
|
93
|
+
if re.search(r"^\s*-?\s*uses\s*:\s*actions/checkout@", text, re.MULTILINE):
|
|
37
94
|
return False
|
|
38
|
-
jobs =
|
|
95
|
+
jobs = re.split(r"(?m)^jobs\s*:\s*(?:#.*)?$", text, maxsplit=1)
|
|
39
96
|
if len(jobs) != 2:
|
|
40
97
|
return False
|
|
41
|
-
blocks = re.split(r"(?m)^ [A-Za-z0-9_-]
|
|
98
|
+
blocks = re.split(r"(?m)^ [A-Za-z0-9_-]+\s*:\s*(?:#.*)?$", jobs[1])[1:]
|
|
42
99
|
return bool(blocks) and all(DEPENDABOT_ONLY_RE.search(block) for block in blocks)
|
|
43
100
|
|
|
44
101
|
|
|
@@ -46,9 +103,11 @@ def scan_text(path, text):
|
|
|
46
103
|
findings = []
|
|
47
104
|
lines = text.splitlines()
|
|
48
105
|
dependabot_target_is_safe = safe_dependabot_target(text)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
106
|
+
unscoped_jobs = jobs_without_permissions(lines)
|
|
107
|
+
if unscoped_jobs:
|
|
108
|
+
findings.append("%s: jobs without explicit permissions: %s. Add a global "
|
|
109
|
+
"least-privilege block or scope every job"
|
|
110
|
+
% (path, ", ".join(unscoped_jobs)))
|
|
52
111
|
for i, line in enumerate(lines, 1):
|
|
53
112
|
if UNQUOTED_NAME_COLON_RE.match(line):
|
|
54
113
|
findings.append("%s:%d: name has an unquoted colon. Quote the complete "
|
package/scripts/validate.sh
CHANGED
|
@@ -15,9 +15,11 @@ good() { printf 'ok: %s\n' "$*"; }
|
|
|
15
15
|
|
|
16
16
|
# ---- 1. bash -n on every shell script ------------------------------------
|
|
17
17
|
section "bash syntax (bash -n)"
|
|
18
|
-
SH_LIST="$ROOT/install.sh $ROOT/uninstall.sh $ROOT/update.sh"
|
|
19
|
-
for f in "$ROOT"/scripts/*.sh "$ROOT"/tests/*.sh "$ROOT"/pack/.claude/hooks/*.sh "$ROOT"/pack/.claude/skills/*/scripts/*.sh; do
|
|
20
|
-
|
|
18
|
+
SH_LIST=("$ROOT/install.sh" "$ROOT/uninstall.sh" "$ROOT/update.sh")
|
|
19
|
+
for f in "$ROOT"/scripts/*.sh "$ROOT"/tests/*.sh "$ROOT"/pack/.claude/hooks/*.sh "$ROOT"/pack/.claude/skills/*/scripts/*.sh; do
|
|
20
|
+
[ -f "$f" ] && SH_LIST+=("$f")
|
|
21
|
+
done
|
|
22
|
+
for f in "${SH_LIST[@]}"; do
|
|
21
23
|
if bash -n "$f" 2>/tmp/dr_synerr; then good "syntax ${f#$ROOT/}"; else bad "syntax ${f#$ROOT/}: $(cat /tmp/dr_synerr)"; fi
|
|
22
24
|
done
|
|
23
25
|
|
|
@@ -67,10 +69,10 @@ done
|
|
|
67
69
|
# ---- 5. frontmatter validation ------------------------------------------
|
|
68
70
|
section "frontmatter"
|
|
69
71
|
if command -v python3 >/dev/null 2>&1; then
|
|
70
|
-
FM_FILES=
|
|
71
|
-
for d in "$SKILLS"/*/; do
|
|
72
|
-
for a in "$AGENTS"/*.md; do
|
|
73
|
-
if python3 "$ROOT/scripts/validate-frontmatter.py" $FM_FILES; then good "frontmatter parses"; else bad "frontmatter validation failed"; fi
|
|
72
|
+
FM_FILES=()
|
|
73
|
+
for d in "$SKILLS"/*/; do [ -f "${d}SKILL.md" ] && FM_FILES+=("${d}SKILL.md"); done
|
|
74
|
+
for a in "$AGENTS"/*.md; do [ -f "$a" ] && FM_FILES+=("$a"); done
|
|
75
|
+
if python3 "$ROOT/scripts/validate-frontmatter.py" "${FM_FILES[@]}"; then good "frontmatter parses"; else bad "frontmatter validation failed"; fi
|
|
74
76
|
else
|
|
75
77
|
echo "skip: python3 not found"
|
|
76
78
|
fi
|
|
@@ -335,11 +337,11 @@ fi
|
|
|
335
337
|
# Local validation skips this gate only when shellcheck is not installed.
|
|
336
338
|
section "shellcheck (-S error blocking · -S warning advisory)"
|
|
337
339
|
if command -v shellcheck >/dev/null 2>&1; then
|
|
338
|
-
for f in $SH_LIST; do
|
|
340
|
+
for f in "${SH_LIST[@]}"; do
|
|
339
341
|
if shellcheck -S error "$f"; then good "shellcheck ${f#"$ROOT"/}"; else bad "shellcheck (error) ${f#"$ROOT"/}"; fi
|
|
340
342
|
done
|
|
341
343
|
# Warnings are advisory. Print them per file without failing the build.
|
|
342
|
-
for f in $SH_LIST; do
|
|
344
|
+
for f in "${SH_LIST[@]}"; do
|
|
343
345
|
shellcheck -S warning "$f" >/dev/null 2>&1 || echo " advisory (warning-level): ${f#"$ROOT"/}"
|
|
344
346
|
done
|
|
345
347
|
else
|