@nimiplatform/nimi-coding 0.4.0 → 0.6.0
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 +16 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +92 -55
- package/README.zh-CN.md +93 -56
- package/cli/commands/authority.mjs +80 -125
- package/cli/commands/clear.mjs +3 -2
- package/cli/commands/code.mjs +225 -0
- package/cli/commands/start.mjs +3 -2
- package/cli/constants.mjs +2 -1
- package/cli/dev/check-authority-surface.mjs +17 -8
- package/cli/dev/check-product-conformance.mjs +4 -32
- package/cli/dev/run-cli-smoke.mjs +6 -0
- package/cli/help.mjs +8 -3
- package/cli/index.mjs +3 -2
- package/cli/lib/authority/audit.mjs +262 -185
- package/cli/lib/authority/change-candidates.mjs +807 -0
- package/cli/lib/authority/closed-sets.mjs +7 -8
- package/cli/lib/authority/compile.mjs +6 -24
- package/cli/lib/authority/compiled-snapshot.mjs +143 -0
- package/cli/lib/authority/consumers.mjs +69 -19
- package/cli/lib/authority/diagnostics.mjs +10 -2
- package/cli/lib/authority/diff.mjs +77 -54
- package/cli/lib/authority/format.mjs +4 -32
- package/cli/lib/authority/git-snapshot.mjs +34 -379
- package/cli/lib/authority/graph.mjs +10 -10
- package/cli/lib/authority/impact.mjs +17 -144
- package/cli/lib/authority/review.mjs +33 -43
- package/cli/lib/authority/sarif.mjs +1 -8
- package/cli/lib/authority/source-yaml.mjs +0 -4
- package/cli/lib/authority/surface.mjs +19 -1
- package/cli/lib/authority/terms.mjs +10 -9
- package/cli/lib/authority/validate.mjs +6 -14
- package/cli/lib/code/authority-annotation-response.mjs +43 -0
- package/cli/lib/code/authority-annotation-scan.mjs +162 -0
- package/cli/lib/code/authority-annotations.mjs +293 -0
- package/cli/lib/code/context.mjs +710 -0
- package/cli/lib/code/response.mjs +165 -0
- package/cli/lib/doctor.mjs +3 -2
- package/cli/lib/entrypoints.mjs +13 -5
- package/cli/seeds/seed-policy.yaml +2 -2
- package/contracts/authority-source.schema.yaml +0 -6
- package/contracts/authority-verifier-bindings.schema.yaml +21 -10
- package/methodology/authority-authoring.yaml +68 -70
- package/methodology/core.yaml +10 -6
- package/package.json +5 -3
- package/spec/authority-audit.yaml +56 -52
- package/spec/authority-authoring.yaml +3 -16
- package/spec/authority-change-candidates.yaml +146 -0
- package/spec/authority-consumers.yaml +7 -0
- package/spec/authority-discovery.yaml +2 -2
- package/spec/authority-graph.yaml +1 -1
- package/spec/authority-impact.yaml +16 -39
- package/spec/authority-review.yaml +29 -47
- package/spec/authority-terms.yaml +5 -1
- package/spec/code-authority-annotations.yaml +42 -0
- package/spec/code-context.yaml +38 -0
- package/spec/product-scope.yaml +15 -9
- package/cli/commands/validate-ai-governance.mjs +0 -335
- package/cli/lib/authority/evidence-snapshot.mjs +0 -1
- package/cli/lib/authority/evidence.mjs +0 -1432
- package/cli/lib/internal/governance/ai/ai-context-budget-core.mjs +0 -377
- package/cli/lib/internal/governance/ai/ai-structure-budget-core.mjs +0 -376
- package/cli/lib/internal/governance/ai/check-agents-freshness.mjs +0 -155
- package/cli/lib/internal/governance/ai/check-high-risk-doc-metadata-core.mjs +0 -173
- package/cli/lib/internal/governance/config.mjs +0 -114
- package/cli/lib/internal/governance/shared/read-yaml-with-fragments.mjs +0 -49
- package/contracts/authority-evidence-bindings.schema.yaml +0 -53
- package/contracts/authority-evidence-probe-results.schema.yaml +0 -38
- package/contracts/authority-impact-dispositions.schema.yaml +0 -13
- package/spec/authority-evidence.yaml +0 -200
|
@@ -6,7 +6,7 @@ import { lstat, mkdir, mkdtemp, open, readdir, realpath, rm, writeFile } from "n
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
|
-
import { compareText, makeDiagnostic } from "./diagnostics.mjs";
|
|
9
|
+
import { compareText, isPathWithin, makeDiagnostic } from "./diagnostics.mjs";
|
|
10
10
|
|
|
11
11
|
const SNAPSHOT_IDENTITY_FORMAT = "nimicoding.authority-snapshot-content/v1";
|
|
12
12
|
const SPEC_ROOT = ".nimi/spec";
|
|
@@ -285,11 +285,6 @@ function sameObject(left, right) {
|
|
|
285
285
|
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
function contained(root, candidate) {
|
|
289
|
-
const relative = path.relative(root, candidate);
|
|
290
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
288
|
function filesystemFailure(error, initial, entryPath = SPEC_ROOT) {
|
|
294
289
|
if (error instanceof AuthorityReviewRefusal) return error;
|
|
295
290
|
if (!initial && ["ENOENT", "ENOTDIR", "ELOOP"].includes(error?.code)) {
|
|
@@ -303,13 +298,13 @@ async function securedDirectory(absolute, containmentRoot, entryPath, initial) {
|
|
|
303
298
|
const before = await lstat(absolute, { bigint: true });
|
|
304
299
|
if (before.isSymbolicLink() || !before.isDirectory()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority entry is not a regular directory: ${entryPath}`, entryPath);
|
|
305
300
|
const beforeReal = await realpath(absolute);
|
|
306
|
-
if (!
|
|
301
|
+
if (!isPathWithin(containmentRoot, beforeReal)) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority directory escapes .nimi/spec: ${entryPath}`, entryPath);
|
|
307
302
|
const handle = await open(absolute, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
308
303
|
try {
|
|
309
304
|
const held = await handle.stat({ bigint: true });
|
|
310
305
|
const current = await lstat(absolute, { bigint: true });
|
|
311
306
|
const currentReal = await realpath(absolute);
|
|
312
|
-
if (!held.isDirectory() || !sameObject(before, held) || !sameObject(held, current) || !
|
|
307
|
+
if (!held.isDirectory() || !sameObject(before, held) || !sameObject(held, current) || !isPathWithin(containmentRoot, currentReal)) {
|
|
313
308
|
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed during capture: ${entryPath}`, entryPath);
|
|
314
309
|
}
|
|
315
310
|
return { handle, token: statToken(held) };
|
|
@@ -327,13 +322,13 @@ async function openSecuredFile(absolute, containmentRoot, entryPath, initial) {
|
|
|
327
322
|
const before = await lstat(absolute, { bigint: true });
|
|
328
323
|
if (before.isSymbolicLink() || !before.isFile()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority entry is not a regular non-symlink file: ${entryPath}`, entryPath);
|
|
329
324
|
const beforeReal = await realpath(absolute);
|
|
330
|
-
if (containmentRoot !== null && !
|
|
325
|
+
if (containmentRoot !== null && !isPathWithin(containmentRoot, beforeReal)) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority file escapes .nimi/spec: ${entryPath}`, entryPath);
|
|
331
326
|
const handle = await open(absolute, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
332
327
|
try {
|
|
333
328
|
const held = await handle.stat({ bigint: true });
|
|
334
329
|
const current = await lstat(absolute, { bigint: true });
|
|
335
330
|
const currentReal = await realpath(absolute);
|
|
336
|
-
if (!held.isFile() || !sameObject(before, held) || !sameObject(held, current) || (containmentRoot !== null && !
|
|
331
|
+
if (!held.isFile() || !sameObject(before, held) || !sameObject(held, current) || (containmentRoot !== null && !isPathWithin(containmentRoot, currentReal))) {
|
|
337
332
|
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before capture: ${entryPath}`, entryPath);
|
|
338
333
|
}
|
|
339
334
|
return { handle, token: statToken(held) };
|
|
@@ -371,7 +366,7 @@ async function readSecuredFile(secured, absolute, containmentRoot, entryPath, in
|
|
|
371
366
|
const heldBefore = await secured.handle.stat({ bigint: true });
|
|
372
367
|
const currentBefore = await lstat(absolute, { bigint: true });
|
|
373
368
|
const currentReal = await realpath(absolute);
|
|
374
|
-
if (!heldBefore.isFile() || statToken(heldBefore) !== secured.token || !sameObject(heldBefore, currentBefore) || (containmentRoot !== null && !
|
|
369
|
+
if (!heldBefore.isFile() || statToken(heldBefore) !== secured.token || !sameObject(heldBefore, currentBefore) || (containmentRoot !== null && !isPathWithin(containmentRoot, currentReal))) {
|
|
375
370
|
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before bytes were captured: ${entryPath}`, entryPath);
|
|
376
371
|
}
|
|
377
372
|
if (budget !== null) budget.reserve(heldBefore.size, entryPath);
|
|
@@ -465,7 +460,7 @@ async function scanWorktree(repository, initial, { retainHandles = false, budget
|
|
|
465
460
|
const current = await lstat(absolute, { bigint: true });
|
|
466
461
|
const currentReal = await realpath(absolute);
|
|
467
462
|
const held = await secured.handle.stat({ bigint: true });
|
|
468
|
-
if (statToken(held) !== secured.token || !sameObject(held, current) || !
|
|
463
|
+
if (statToken(held) !== secured.token || !sameObject(held, current) || !isPathWithin(specReal, currentReal)) {
|
|
469
464
|
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed during traversal: ${entryPath}`, entryPath);
|
|
470
465
|
}
|
|
471
466
|
} finally {
|
|
@@ -497,7 +492,7 @@ async function verifyHeldEntries(heldEntries) {
|
|
|
497
492
|
const held = await entry.handle.stat({ bigint: true });
|
|
498
493
|
const current = await lstat(entry.absolute, { bigint: true });
|
|
499
494
|
const currentReal = await realpath(entry.absolute);
|
|
500
|
-
if (!held.isDirectory() || statToken(held) !== entry.token || !sameObject(held, current) || !
|
|
495
|
+
if (!held.isDirectory() || statToken(held) !== entry.token || !sameObject(held, current) || !isPathWithin(entry.containmentRoot, currentReal)) {
|
|
501
496
|
throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed before capture commit: ${entry.path}`, entry.path);
|
|
502
497
|
}
|
|
503
498
|
} catch (error) {
|
|
@@ -594,7 +589,7 @@ function stableRegularFileBudget(maxBytes, label) {
|
|
|
594
589
|
return {
|
|
595
590
|
reserve(size) {
|
|
596
591
|
if (size > BigInt(maxBytes)) {
|
|
597
|
-
throw refusal("
|
|
592
|
+
throw refusal("AUTH_REVIEW_INPUT_BUDGET", `review input requires ${size} bytes but maxBytes is ${maxBytes}: ${label}`, label);
|
|
598
593
|
}
|
|
599
594
|
},
|
|
600
595
|
};
|
|
@@ -618,204 +613,6 @@ export async function captureStableRegularFile(file, label, containmentRoot = nu
|
|
|
618
613
|
}
|
|
619
614
|
}
|
|
620
615
|
|
|
621
|
-
function evidenceRefusal(code, reason, file = ".") {
|
|
622
|
-
return refusal(code, reason, file);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function mapEvidenceRefusal(error, fallbackCode, fallbackFile = ".") {
|
|
626
|
-
if (!(error instanceof AuthorityReviewRefusal)) return error;
|
|
627
|
-
if (error.code === "AUTH_REVIEW_CAPTURE_CHANGED" || error.code === "AUTH_EVIDENCE_CAPTURE_CHANGED") {
|
|
628
|
-
return evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", error.message, error.file ?? fallbackFile);
|
|
629
|
-
}
|
|
630
|
-
if (error.code.startsWith("AUTH_EVIDENCE_")) return error;
|
|
631
|
-
return evidenceRefusal(fallbackCode, error.message, error.file ?? fallbackFile);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function validEvidencePath(value) {
|
|
635
|
-
return typeof value === "string"
|
|
636
|
-
&& validateRelativePath(value)
|
|
637
|
-
&& value === value.normalize("NFC")
|
|
638
|
-
&& !value.includes("\0")
|
|
639
|
-
&& !value.includes("\n")
|
|
640
|
-
&& !value.includes("\r")
|
|
641
|
-
&& value.split("/").every((part) => part.toLowerCase() !== ".git");
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function evidenceInputBudget(maxInputBytes) {
|
|
645
|
-
if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes <= 0) {
|
|
646
|
-
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_BUDGET", "maxInputBytes must be one positive safe integer");
|
|
647
|
-
}
|
|
648
|
-
let captured = 0;
|
|
649
|
-
return {
|
|
650
|
-
reserve(size, entryPath) {
|
|
651
|
-
if (size < 0n || size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
652
|
-
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_BUDGET", `evidence input is too large to capture exactly: ${entryPath}`, entryPath);
|
|
653
|
-
}
|
|
654
|
-
const required = BigInt(captured) + size;
|
|
655
|
-
if (required > BigInt(maxInputBytes)) {
|
|
656
|
-
throw evidenceRefusal(
|
|
657
|
-
"AUTH_EVIDENCE_INPUT_BUDGET",
|
|
658
|
-
`complete evidence snapshot requires at least ${required} input bytes but maxInputBytes is ${maxInputBytes}`,
|
|
659
|
-
entryPath,
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
captured = Number(required);
|
|
663
|
-
},
|
|
664
|
-
get captured() {
|
|
665
|
-
return captured;
|
|
666
|
-
},
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
async function trackedRegularIndexRecord(repository, bindingPath) {
|
|
671
|
-
const result = await runGit(repository, ["ls-files", "--stage", "-z", "--error-unmatch", "--", bindingPath]);
|
|
672
|
-
if (result.signal !== null) throw new Error(`Git process terminated by signal ${result.signal}`);
|
|
673
|
-
if (result.status !== 0) {
|
|
674
|
-
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "evidence binding must be one tracked regular repository path", bindingPath);
|
|
675
|
-
}
|
|
676
|
-
const records = splitNullRecords(result.stdout);
|
|
677
|
-
if (records.length !== 1) {
|
|
678
|
-
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "evidence binding must resolve to exactly one non-conflicted index entry", bindingPath);
|
|
679
|
-
}
|
|
680
|
-
const tab = records[0].indexOf(9);
|
|
681
|
-
if (tab <= 0 || tab === records[0].length - 1) {
|
|
682
|
-
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "tracked evidence binding index entry is malformed", bindingPath);
|
|
683
|
-
}
|
|
684
|
-
const metadata = decodeUtf8(records[0].subarray(0, tab), "tracked evidence binding metadata", "AUTH_EVIDENCE_BINDING_INVALID");
|
|
685
|
-
const indexedPath = decodeUtf8(records[0].subarray(tab + 1), "tracked evidence binding path", "AUTH_EVIDENCE_BINDING_INVALID");
|
|
686
|
-
if (!/^(?:100644|100755) (?:[0-9a-f]{40}|[0-9a-f]{64}) 0$/.test(metadata) || indexedPath !== bindingPath) {
|
|
687
|
-
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "evidence binding must be one exact stage-zero regular Git entry", bindingPath);
|
|
688
|
-
}
|
|
689
|
-
return Buffer.from(records[0]);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
async function captureEvidenceInput(repository, descriptor, budget, retainHandles) {
|
|
693
|
-
const heldEntries = [];
|
|
694
|
-
const pathTokens = [];
|
|
695
|
-
const parts = descriptor.path.split("/");
|
|
696
|
-
let absolute = repository;
|
|
697
|
-
let retained = false;
|
|
698
|
-
|
|
699
|
-
async function retainDirectory(directory, entryPath) {
|
|
700
|
-
const secured = await securedDirectory(directory, repository, entryPath, true);
|
|
701
|
-
pathTokens.push({ path: entryPath, token: secured.token });
|
|
702
|
-
if (retainHandles) heldEntries.push({ path: entryPath, type: "directory", absolute: directory, containmentRoot: repository, ...secured });
|
|
703
|
-
else await secured.handle.close();
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function missing(missingAbsolute, missingPath) {
|
|
707
|
-
if (!descriptor.allowMissing) {
|
|
708
|
-
throw evidenceRefusal(descriptor.invalidCode, `${descriptor.role} input is missing: ${descriptor.path}`, descriptor.path);
|
|
709
|
-
}
|
|
710
|
-
retained = retainHandles;
|
|
711
|
-
return {
|
|
712
|
-
role: descriptor.role,
|
|
713
|
-
path: descriptor.path,
|
|
714
|
-
type: "missing",
|
|
715
|
-
bytes: null,
|
|
716
|
-
token: null,
|
|
717
|
-
pathTokens,
|
|
718
|
-
heldEntries,
|
|
719
|
-
missingAbsolute,
|
|
720
|
-
missingPath,
|
|
721
|
-
};
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
try {
|
|
725
|
-
await retainDirectory(repository, ".");
|
|
726
|
-
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
727
|
-
absolute = path.join(absolute, parts[index]);
|
|
728
|
-
const entryPath = parts.slice(0, index + 1).join("/");
|
|
729
|
-
try {
|
|
730
|
-
const info = await lstat(absolute, { bigint: true });
|
|
731
|
-
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
732
|
-
throw evidenceRefusal(descriptor.invalidCode, `${descriptor.role} ancestor is not one regular non-symlink directory: ${entryPath}`, entryPath);
|
|
733
|
-
}
|
|
734
|
-
} catch (error) {
|
|
735
|
-
if (error?.code === "ENOENT") return missing(absolute, entryPath);
|
|
736
|
-
throw error;
|
|
737
|
-
}
|
|
738
|
-
await retainDirectory(absolute, entryPath);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const target = path.join(repository, ...parts);
|
|
742
|
-
try {
|
|
743
|
-
const info = await lstat(target, { bigint: true });
|
|
744
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
745
|
-
throw evidenceRefusal(descriptor.invalidCode, `${descriptor.role} input is not one regular non-symlink file: ${descriptor.path}`, descriptor.path);
|
|
746
|
-
}
|
|
747
|
-
} catch (error) {
|
|
748
|
-
if (error?.code === "ENOENT") return missing(target, descriptor.path);
|
|
749
|
-
throw error;
|
|
750
|
-
}
|
|
751
|
-
const captured = await securedFile(target, repository, descriptor.path, true, retainHandles, budget);
|
|
752
|
-
if (retainHandles) heldEntries.push({ path: descriptor.path, type: "regular-file", absolute: target, containmentRoot: repository, ...captured });
|
|
753
|
-
retained = retainHandles;
|
|
754
|
-
return {
|
|
755
|
-
role: descriptor.role,
|
|
756
|
-
path: descriptor.path,
|
|
757
|
-
type: "regular-file",
|
|
758
|
-
bytes: captured.bytes,
|
|
759
|
-
token: captured.token,
|
|
760
|
-
pathTokens,
|
|
761
|
-
heldEntries,
|
|
762
|
-
missingAbsolute: null,
|
|
763
|
-
missingPath: null,
|
|
764
|
-
};
|
|
765
|
-
} catch (error) {
|
|
766
|
-
throw mapEvidenceRefusal(error, descriptor.invalidCode, descriptor.path);
|
|
767
|
-
} finally {
|
|
768
|
-
if (!retained) await closeHeldEntries(heldEntries);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
function sameEvidenceInput(left, right) {
|
|
773
|
-
if (left.role !== right.role
|
|
774
|
-
|| left.path !== right.path
|
|
775
|
-
|| left.type !== right.type
|
|
776
|
-
|| left.token !== right.token
|
|
777
|
-
|| left.pathTokens.length !== right.pathTokens.length) return false;
|
|
778
|
-
for (let index = 0; index < left.pathTokens.length; index += 1) {
|
|
779
|
-
if (left.pathTokens[index].path !== right.pathTokens[index].path
|
|
780
|
-
|| left.pathTokens[index].token !== right.pathTokens[index].token) return false;
|
|
781
|
-
}
|
|
782
|
-
return left.type === "missing" || left.bytes.equals(right.bytes);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
async function verifyEvidenceInput(entry) {
|
|
786
|
-
try {
|
|
787
|
-
await verifyHeldEntries(entry.heldEntries);
|
|
788
|
-
} catch (error) {
|
|
789
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `${entry.role} changed before evidence capture commit: ${entry.path}; ${error.message}`, entry.path);
|
|
790
|
-
}
|
|
791
|
-
if (entry.type !== "missing") return;
|
|
792
|
-
try {
|
|
793
|
-
await lstat(entry.missingAbsolute, { bigint: true });
|
|
794
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `missing locator appeared during capture: ${entry.path}`, entry.path);
|
|
795
|
-
} catch (error) {
|
|
796
|
-
if (error?.code === "ENOENT") return;
|
|
797
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_CAPTURE_CHANGED", entry.path);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function semanticEvidenceInput(entry) {
|
|
802
|
-
return {
|
|
803
|
-
role: entry.role,
|
|
804
|
-
path: entry.path,
|
|
805
|
-
type: entry.type,
|
|
806
|
-
bytes: entry.bytes,
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function evidenceInputMetadata(entries) {
|
|
811
|
-
return entries.map((entry) => ({
|
|
812
|
-
role: entry.role,
|
|
813
|
-
path: entry.path,
|
|
814
|
-
type: entry.type,
|
|
815
|
-
bytes: entry.bytes?.length ?? 0,
|
|
816
|
-
}));
|
|
817
|
-
}
|
|
818
|
-
|
|
819
616
|
async function createTemporaryRoot(protectedRoots, prefix = "nimicoding-authority-review-") {
|
|
820
617
|
let base;
|
|
821
618
|
try {
|
|
@@ -825,14 +622,14 @@ async function createTemporaryRoot(protectedRoots, prefix = "nimicoding-authorit
|
|
|
825
622
|
} catch {
|
|
826
623
|
throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "system temporary directory is not one readable physical directory");
|
|
827
624
|
}
|
|
828
|
-
if (protectedRoots.some((protectedRoot) =>
|
|
625
|
+
if (protectedRoots.some((protectedRoot) => isPathWithin(protectedRoot, base))) {
|
|
829
626
|
throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "system temporary directory must be outside the reviewed worktree and Git administration directories");
|
|
830
627
|
}
|
|
831
628
|
const created = await mkdtemp(path.join(base, prefix));
|
|
832
629
|
try {
|
|
833
630
|
const temporaryRoot = await realpath(created);
|
|
834
631
|
const info = await lstat(temporaryRoot);
|
|
835
|
-
if (!info.isDirectory() || protectedRoots.some((protectedRoot) =>
|
|
632
|
+
if (!info.isDirectory() || protectedRoots.some((protectedRoot) => isPathWithin(protectedRoot, temporaryRoot) || isPathWithin(temporaryRoot, protectedRoot))) {
|
|
836
633
|
throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "authority snapshots require one isolated temporary directory outside the reviewed repository");
|
|
837
634
|
}
|
|
838
635
|
return temporaryRoot;
|
|
@@ -842,188 +639,46 @@ async function createTemporaryRoot(protectedRoots, prefix = "nimicoding-authorit
|
|
|
842
639
|
}
|
|
843
640
|
}
|
|
844
641
|
|
|
845
|
-
function evidenceDescriptors(bindingPath, probeResultsPath, locatorPaths) {
|
|
846
|
-
if (!validEvidencePath(bindingPath) || !bindingPath.startsWith(".nimi/config/")) {
|
|
847
|
-
throw evidenceRefusal("AUTH_EVIDENCE_BINDING_INVALID", "bindingPath must be one repository-relative path nested under .nimi/config", String(bindingPath ?? "."));
|
|
848
|
-
}
|
|
849
|
-
if (probeResultsPath !== null && probeResultsPath !== undefined && !validEvidencePath(probeResultsPath)) {
|
|
850
|
-
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_INVALID", "probeResultsPath must be one safe repository-relative regular-file path", String(probeResultsPath));
|
|
851
|
-
}
|
|
852
|
-
if (!Array.isArray(locatorPaths) || locatorPaths.some((entry) => !validEvidencePath(entry))) {
|
|
853
|
-
throw evidenceRefusal("AUTH_EVIDENCE_LOCATOR_INVALID", "locatorPaths must be one list of safe repository-relative paths");
|
|
854
|
-
}
|
|
855
|
-
const orderedLocators = [...locatorPaths].sort(compareText);
|
|
856
|
-
if (new Set(orderedLocators.map((entry) => entry.toLowerCase())).size !== orderedLocators.length) {
|
|
857
|
-
throw evidenceRefusal("AUTH_EVIDENCE_LOCATOR_INVALID", "locatorPaths must not contain duplicate or portable-colliding paths");
|
|
858
|
-
}
|
|
859
|
-
const allPaths = [bindingPath, ...(probeResultsPath === null || probeResultsPath === undefined ? [] : [probeResultsPath]), ...orderedLocators];
|
|
860
|
-
if (new Set(allPaths.map((entry) => entry.toLowerCase())).size !== allPaths.length) {
|
|
861
|
-
throw evidenceRefusal("AUTH_EVIDENCE_INPUT_INVALID", "binding, probe results, and locator paths must be logically distinct without portable collisions");
|
|
862
|
-
}
|
|
863
|
-
return [
|
|
864
|
-
{ role: "binding", path: bindingPath, allowMissing: false, invalidCode: "AUTH_EVIDENCE_BINDING_INVALID" },
|
|
865
|
-
...(probeResultsPath === null || probeResultsPath === undefined ? [] : [{ role: "probe-results", path: probeResultsPath, allowMissing: false, invalidCode: "AUTH_EVIDENCE_INPUT_INVALID" }]),
|
|
866
|
-
...orderedLocators.map((entryPath) => ({ role: "locator", path: entryPath, allowMissing: true, invalidCode: "AUTH_EVIDENCE_LOCATOR_INVALID" })),
|
|
867
|
-
];
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
export async function withGitEvidenceSnapshot({
|
|
871
|
-
repositoryPath,
|
|
872
|
-
bindingPath,
|
|
873
|
-
probeResultsPath = null,
|
|
874
|
-
locatorPaths,
|
|
875
|
-
maxInputBytes,
|
|
876
|
-
hooks = null,
|
|
877
|
-
}, callback) {
|
|
878
|
-
if (typeof callback !== "function") throw evidenceRefusal("AUTH_EVIDENCE_INPUT_INVALID", "evidence snapshot requires one callback");
|
|
879
|
-
const budget = evidenceInputBudget(maxInputBytes);
|
|
880
|
-
const descriptors = evidenceDescriptors(bindingPath, probeResultsPath, locatorPaths);
|
|
881
|
-
let resolved;
|
|
882
|
-
try {
|
|
883
|
-
resolved = await resolveRepository(repositoryPath);
|
|
884
|
-
} catch (error) {
|
|
885
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_REPOSITORY_INVALID");
|
|
886
|
-
}
|
|
887
|
-
const { repository, protectedRoots } = resolved;
|
|
888
|
-
let headOid;
|
|
889
|
-
try {
|
|
890
|
-
headOid = await resolveBaseOid(repository, "HEAD");
|
|
891
|
-
} catch (error) {
|
|
892
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_HEAD_INVALID");
|
|
893
|
-
}
|
|
894
|
-
if (hooks?.afterHeadResolved) await hooks.afterHeadResolved({ repository, headOid });
|
|
895
|
-
|
|
896
|
-
let trackedBinding;
|
|
897
|
-
try {
|
|
898
|
-
trackedBinding = await trackedRegularIndexRecord(repository, bindingPath);
|
|
899
|
-
} catch (error) {
|
|
900
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_BINDING_INVALID", bindingPath);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
let authorityEntries;
|
|
904
|
-
let semanticInputs;
|
|
905
|
-
let authorityHeld = [];
|
|
906
|
-
const capturedInputs = [];
|
|
907
|
-
try {
|
|
908
|
-
let firstAuthority;
|
|
909
|
-
try {
|
|
910
|
-
const captured = await scanWorktree(repository, true, { retainHandles: true, budget });
|
|
911
|
-
firstAuthority = captured.entries;
|
|
912
|
-
authorityHeld = captured.heldEntries;
|
|
913
|
-
} catch (error) {
|
|
914
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_AUTHORITY_INVALID", SPEC_ROOT);
|
|
915
|
-
}
|
|
916
|
-
if (hooks?.afterAuthorityCapture) {
|
|
917
|
-
await hooks.afterAuthorityCapture({
|
|
918
|
-
repository,
|
|
919
|
-
headOid,
|
|
920
|
-
authority: semanticEntries(firstAuthority).map((entry) => ({ path: entry.path, type: entry.type, bytes: entry.bytes?.length ?? 0 })),
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
for (const descriptor of descriptors) capturedInputs.push(await captureEvidenceInput(repository, descriptor, budget, true));
|
|
924
|
-
if (hooks?.afterInitialCapture) {
|
|
925
|
-
await hooks.afterInitialCapture({ repository, headOid, inputs: evidenceInputMetadata(capturedInputs) });
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
const recaptureBudget = evidenceInputBudget(maxInputBytes);
|
|
929
|
-
let secondAuthority;
|
|
930
|
-
try {
|
|
931
|
-
secondAuthority = (await scanWorktree(repository, false, { budget: recaptureBudget })).entries;
|
|
932
|
-
} catch (error) {
|
|
933
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `authority inventory changed during evidence capture: ${error.message}`, SPEC_ROOT);
|
|
934
|
-
}
|
|
935
|
-
if (!compareCapturedEntries(firstAuthority, secondAuthority, true)) {
|
|
936
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", "authority paths, types, metadata, or bytes changed during evidence capture", SPEC_ROOT);
|
|
937
|
-
}
|
|
938
|
-
for (let index = 0; index < descriptors.length; index += 1) {
|
|
939
|
-
let second;
|
|
940
|
-
try {
|
|
941
|
-
second = await captureEvidenceInput(repository, descriptors[index], recaptureBudget, false);
|
|
942
|
-
} catch (error) {
|
|
943
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `${descriptors[index].role} input changed during evidence capture: ${descriptors[index].path}; ${error.message}`, descriptors[index].path);
|
|
944
|
-
}
|
|
945
|
-
if (!sameEvidenceInput(capturedInputs[index], second)) {
|
|
946
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `${descriptors[index].role} path, type, metadata, or bytes changed during evidence capture: ${descriptors[index].path}`, descriptors[index].path);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
const secondTrackedBinding = await trackedRegularIndexRecord(repository, bindingPath).catch((error) => {
|
|
950
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `evidence binding tracking state changed during capture: ${error.message}`, bindingPath);
|
|
951
|
-
});
|
|
952
|
-
if (!trackedBinding.equals(secondTrackedBinding)) {
|
|
953
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", "evidence binding index identity changed during capture", bindingPath);
|
|
954
|
-
}
|
|
955
|
-
if (hooks?.beforeCaptureCommit) {
|
|
956
|
-
await hooks.beforeCaptureCommit({ repository, headOid, inputs: evidenceInputMetadata(capturedInputs) });
|
|
957
|
-
}
|
|
958
|
-
await verifyHeldEntries(authorityHeld).catch((error) => {
|
|
959
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `authority changed before evidence capture commit: ${error.message}`, SPEC_ROOT);
|
|
960
|
-
});
|
|
961
|
-
for (const entry of capturedInputs) await verifyEvidenceInput(entry);
|
|
962
|
-
const committedTrackedBinding = await trackedRegularIndexRecord(repository, bindingPath).catch((error) => {
|
|
963
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", `evidence binding tracking state changed before capture commit: ${error.message}`, bindingPath);
|
|
964
|
-
});
|
|
965
|
-
if (!trackedBinding.equals(committedTrackedBinding)) {
|
|
966
|
-
throw evidenceRefusal("AUTH_EVIDENCE_CAPTURE_CHANGED", "evidence binding index identity changed before capture commit", bindingPath);
|
|
967
|
-
}
|
|
968
|
-
authorityEntries = semanticEntries(firstAuthority);
|
|
969
|
-
semanticInputs = capturedInputs.map(semanticEvidenceInput);
|
|
970
|
-
} finally {
|
|
971
|
-
await closeHeldEntries(authorityHeld);
|
|
972
|
-
await Promise.all(capturedInputs.map((entry) => closeHeldEntries(entry.heldEntries)));
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
let temporaryRoot;
|
|
976
|
-
try {
|
|
977
|
-
temporaryRoot = await createTemporaryRoot(protectedRoots, "nimicoding-authority-evidence-");
|
|
978
|
-
} catch (error) {
|
|
979
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_TEMPORARY_INVALID");
|
|
980
|
-
}
|
|
981
|
-
const authorityRoot = path.join(temporaryRoot, "authority");
|
|
982
|
-
try {
|
|
983
|
-
await mkdir(authorityRoot, { mode: 0o700 });
|
|
984
|
-
try {
|
|
985
|
-
await materializeEntries(authorityRoot, authorityEntries, "AUTH_EVIDENCE_AUTHORITY_INVALID");
|
|
986
|
-
} catch (error) {
|
|
987
|
-
throw mapEvidenceRefusal(error, "AUTH_EVIDENCE_AUTHORITY_INVALID", SPEC_ROOT);
|
|
988
|
-
}
|
|
989
|
-
const counts = snapshotCounts(authorityEntries);
|
|
990
|
-
return await callback({
|
|
991
|
-
repository,
|
|
992
|
-
headOid,
|
|
993
|
-
temporaryRoot,
|
|
994
|
-
authority: {
|
|
995
|
-
root: authorityRoot,
|
|
996
|
-
contentIdentity: authoritySnapshotContentIdentity(authorityEntries),
|
|
997
|
-
...counts,
|
|
998
|
-
},
|
|
999
|
-
inputs: semanticInputs,
|
|
1000
|
-
capturedInputBytes: budget.captured,
|
|
1001
|
-
});
|
|
1002
|
-
} finally {
|
|
1003
|
-
await rm(temporaryRoot, { recursive: true, force: true });
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
642
|
export async function listGitTrackedFiles(repositoryPath) {
|
|
1008
643
|
const { repository } = await resolveRepository(repositoryPath);
|
|
1009
644
|
const bytes = await checkedGit(
|
|
1010
645
|
repository,
|
|
1011
|
-
["ls-files", "--
|
|
646
|
+
["ls-files", "--stage", "--full-name", "-z"],
|
|
1012
647
|
"AUTH_REVIEW_GIT_INDEX_INVALID",
|
|
1013
648
|
"Git tracked-file inventory cannot be read",
|
|
1014
649
|
);
|
|
1015
650
|
const files = [];
|
|
651
|
+
const gitlinks = [];
|
|
1016
652
|
let offset = 0;
|
|
1017
653
|
while (offset < bytes.length) {
|
|
1018
654
|
const end = bytes.indexOf(0, offset);
|
|
1019
655
|
if (end < 0) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory is not NUL-terminated");
|
|
1020
656
|
if (end === offset) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an empty path");
|
|
1021
|
-
const
|
|
657
|
+
const record = bytes.subarray(offset, end);
|
|
658
|
+
const tab = record.indexOf(9);
|
|
659
|
+
if (tab <= 0 || tab === record.length - 1) {
|
|
660
|
+
throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains a malformed index entry");
|
|
661
|
+
}
|
|
662
|
+
const metadata = decodeUtf8(
|
|
663
|
+
record.subarray(0, tab),
|
|
664
|
+
"Git tracked-file metadata",
|
|
665
|
+
"AUTH_REVIEW_GIT_INDEX_INVALID",
|
|
666
|
+
);
|
|
667
|
+
const match = /^(100644|100755|120000|160000) (?:[0-9a-f]{40}|[0-9a-f]{64}) ([0-3])$/.exec(metadata);
|
|
668
|
+
if (match === null) {
|
|
669
|
+
throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an unsupported index entry");
|
|
670
|
+
}
|
|
671
|
+
const file = decodeUtf8(record.subarray(tab + 1), "Git tracked-file path", "AUTH_REVIEW_GIT_INDEX_INVALID");
|
|
1022
672
|
if (!validateRelativePath(file)) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an unsafe path");
|
|
1023
673
|
files.push(file);
|
|
674
|
+
if (match[1] === "160000") gitlinks.push(file);
|
|
1024
675
|
offset = end + 1;
|
|
1025
676
|
}
|
|
1026
|
-
return {
|
|
677
|
+
return {
|
|
678
|
+
repository,
|
|
679
|
+
files: [...new Set(files)].sort(compareText),
|
|
680
|
+
gitlinks: [...new Set(gitlinks)].sort(compareText),
|
|
681
|
+
};
|
|
1027
682
|
}
|
|
1028
683
|
|
|
1029
684
|
export async function withGitAuthoritySnapshots({ repositoryPath, baseRef, hooks = null }, callback) {
|
|
@@ -164,14 +164,14 @@ function finish(compiled, graph, location, root, traversal) {
|
|
|
164
164
|
compiled,
|
|
165
165
|
location,
|
|
166
166
|
root.unit.id,
|
|
167
|
-
`complete graph
|
|
167
|
+
`complete graph traversal requires ${traversal.units} units but max-units is ${maxUnits}`,
|
|
168
168
|
root.idMap,
|
|
169
169
|
);
|
|
170
170
|
if (traversal.edges > maxEdges) return budgetFailure(
|
|
171
171
|
compiled,
|
|
172
172
|
location,
|
|
173
173
|
root.unit.id,
|
|
174
|
-
`complete graph
|
|
174
|
+
`complete graph traversal requires ${traversal.edges} authored edges but max-edges is ${maxEdges}`,
|
|
175
175
|
root.idMap,
|
|
176
176
|
);
|
|
177
177
|
const graphBytes = Buffer.byteLength(JSON.stringify(graph), "utf8");
|
|
@@ -245,7 +245,7 @@ export async function pathAuthorityPath(inputPath, fromId, toId, { traversal: tr
|
|
|
245
245
|
const { compiled, location, snapshot } = admitted;
|
|
246
246
|
const root = snapshot.byId.get(fromId);
|
|
247
247
|
const visitedDepth = new Map([[fromId, 0]]);
|
|
248
|
-
const
|
|
248
|
+
const traversedEdges = new Set();
|
|
249
249
|
let layer = new Map([[fromId, []]]);
|
|
250
250
|
let witness = fromId === toId ? [] : null;
|
|
251
251
|
let exhausted = false;
|
|
@@ -255,7 +255,7 @@ export async function pathAuthorityPath(inputPath, fromId, toId, { traversal: tr
|
|
|
255
255
|
for (const id of [...layer.keys()].sort(compareText)) {
|
|
256
256
|
const prefix = layer.get(id);
|
|
257
257
|
for (const value of pathAdjacency(snapshot, id, traversalMode)) {
|
|
258
|
-
|
|
258
|
+
traversedEdges.add(edgeKey(value.edge));
|
|
259
259
|
const priorDepth = visitedDepth.get(value.next);
|
|
260
260
|
if (priorDepth !== undefined && priorDepth < depth + 1) continue;
|
|
261
261
|
const candidate = [...prefix, { source: value.edge.source, type: value.edge.type, target: value.edge.target, traversal: value.traversal }];
|
|
@@ -273,7 +273,7 @@ export async function pathAuthorityPath(inputPath, fromId, toId, { traversal: tr
|
|
|
273
273
|
let unchecked = false;
|
|
274
274
|
for (const id of [...layer.keys()].sort(compareText)) {
|
|
275
275
|
for (const value of pathAdjacency(snapshot, id, traversalMode)) {
|
|
276
|
-
|
|
276
|
+
traversedEdges.add(edgeKey(value.edge));
|
|
277
277
|
if (!visitedDepth.has(value.next)) unchecked = true;
|
|
278
278
|
}
|
|
279
279
|
}
|
|
@@ -293,7 +293,7 @@ export async function pathAuthorityPath(inputPath, fromId, toId, { traversal: tr
|
|
|
293
293
|
? [fromId, ...(witness ?? []).map((step) => step.traversal === "forward" ? step.target : step.source)]
|
|
294
294
|
: [fromId, toId];
|
|
295
295
|
const nodes = [...new Set(pathNodeIds)].map((id) => snapshot.byId.get(id).node);
|
|
296
|
-
const traversal = { units: new Set([...visitedDepth.keys(), toId]).size, edges:
|
|
296
|
+
const traversal = { units: new Set([...visitedDepth.keys(), toId]).size, edges: traversedEdges.size };
|
|
297
297
|
const budgets = { maxHops, maxUnits, maxEdges, maxBytes };
|
|
298
298
|
const graph = graphBase("path", { fromId, toId, traversal: traversalMode, relations }, nodes, edges, {
|
|
299
299
|
edgeRepresentation: "canonical_authored_source_to_target",
|
|
@@ -315,13 +315,13 @@ export async function subgraphAuthorityPath(inputPath, id, { direction, relation
|
|
|
315
315
|
const { compiled, location, snapshot } = admitted;
|
|
316
316
|
const root = snapshot.byId.get(id);
|
|
317
317
|
const distances = new Map([[id, 0]]);
|
|
318
|
-
const
|
|
318
|
+
const traversedEdges = new Set();
|
|
319
319
|
let layer = [id];
|
|
320
320
|
for (let currentDepth = 0; currentDepth < depth && layer.length > 0; currentDepth += 1) {
|
|
321
321
|
const next = new Set();
|
|
322
322
|
for (const current of [...layer].sort(compareText)) {
|
|
323
323
|
for (const value of adjacent(snapshot, current, direction)) {
|
|
324
|
-
|
|
324
|
+
traversedEdges.add(edgeKey(value.edge));
|
|
325
325
|
if (!distances.has(value.next)) {
|
|
326
326
|
distances.set(value.next, currentDepth + 1);
|
|
327
327
|
next.add(value.next);
|
|
@@ -332,9 +332,9 @@ export async function subgraphAuthorityPath(inputPath, id, { direction, relation
|
|
|
332
332
|
}
|
|
333
333
|
const nodeIds = new Set(distances.keys());
|
|
334
334
|
const edges = snapshot.edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target));
|
|
335
|
-
for (const edge of edges)
|
|
335
|
+
for (const edge of edges) traversedEdges.add(edgeKey(edge));
|
|
336
336
|
const nodes = [...nodeIds].map((nodeId) => snapshot.byId.get(nodeId).node);
|
|
337
|
-
const traversal = { units: nodes.length, edges:
|
|
337
|
+
const traversal = { units: nodes.length, edges: traversedEdges.size };
|
|
338
338
|
const budgets = { depth, maxUnits, maxEdges, maxBytes };
|
|
339
339
|
const graph = graphBase("subgraph", { id, direction, relations, depth }, nodes, edges, {
|
|
340
340
|
edgeRepresentation: "canonical_authored_source_to_target",
|