filegrc 0.4.0 → 0.5.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/README.md +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +961 -202
- package/src/workflow.js +1595 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { applyResourceBatch } from "./files.js";
|
|
3
|
+
import { getGitSummary } from "./git.js";
|
|
4
|
+
import { loadWorkspace } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
export function collectionRevision(loaded, resourceType) {
|
|
7
|
+
const records = loaded.entries
|
|
8
|
+
.filter(({ record }) => record.type === resourceType)
|
|
9
|
+
.map(({ record, source }) => ({
|
|
10
|
+
id: record.id,
|
|
11
|
+
revision: createHash("sha256").update(source).digest("hex")
|
|
12
|
+
}))
|
|
13
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
14
|
+
const workspaceScope = {
|
|
15
|
+
assuranceGoal: loaded.workspace?.assuranceGoal ?? null,
|
|
16
|
+
candidateCoverage: loaded.workspace?.candidateCoverage ?? null,
|
|
17
|
+
systemIds: [...(loaded.workspace?.systemIds || [])].sort(),
|
|
18
|
+
frameworkIds: [...(loaded.workspace?.frameworkIds || [])].sort(),
|
|
19
|
+
requirementIds: [...(loaded.workspace?.requirementIds || [])].sort(),
|
|
20
|
+
controlIds: [...(loaded.workspace?.controlIds || [])].sort()
|
|
21
|
+
};
|
|
22
|
+
return createHash("sha256")
|
|
23
|
+
.update(JSON.stringify({ resourceType, records, workspaceScope }))
|
|
24
|
+
.digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assessCollectionReviews(input) {
|
|
28
|
+
const loaded = input?.resources && input?.model && input?.entries
|
|
29
|
+
? input
|
|
30
|
+
: null;
|
|
31
|
+
if (!loaded) throw new Error("Collection review assessment requires a loaded workspace.");
|
|
32
|
+
return Object.keys(loaded.model.collectionReviews || {})
|
|
33
|
+
.map((resourceType) => assessCollectionReview(loaded, resourceType));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assessCollectionReview(loaded, resourceType) {
|
|
37
|
+
const configuration = loaded.model.collectionReviews?.[resourceType];
|
|
38
|
+
if (!configuration) return null;
|
|
39
|
+
const records = loaded.resources.filter((record) => record.type === resourceType);
|
|
40
|
+
const reviewEntry = loaded.entries.find(({ record }) => (
|
|
41
|
+
record.type === "collection-review"
|
|
42
|
+
&& record.resourceType === resourceType
|
|
43
|
+
&& record.status !== "retired"
|
|
44
|
+
));
|
|
45
|
+
const review = reviewEntry?.record || null;
|
|
46
|
+
const currentRevision = collectionRevision(loaded, resourceType);
|
|
47
|
+
const allowedDecisions = configuration.decisions || ["complete"];
|
|
48
|
+
const complete = Boolean(
|
|
49
|
+
review?.status === "active"
|
|
50
|
+
&& allowedDecisions.includes(review.decision)
|
|
51
|
+
&& review.collectionRevision === currentRevision
|
|
52
|
+
);
|
|
53
|
+
const stale = Boolean(
|
|
54
|
+
review?.status === "active"
|
|
55
|
+
&& review.collectionRevision
|
|
56
|
+
&& review.collectionRevision !== currentRevision
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
resourceType,
|
|
60
|
+
configuration,
|
|
61
|
+
records,
|
|
62
|
+
recordCount: records.length,
|
|
63
|
+
review,
|
|
64
|
+
reviewRevision: reviewEntry
|
|
65
|
+
? createHash("sha256").update(reviewEntry.source).digest("hex")
|
|
66
|
+
: null,
|
|
67
|
+
collectionRevision: currentRevision,
|
|
68
|
+
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
69
|
+
complete,
|
|
70
|
+
message: complete
|
|
71
|
+
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
72
|
+
: stale
|
|
73
|
+
? `${configuration.title} changed after the last confirmation. Review the current records again.`
|
|
74
|
+
: `Review ${configuration.title.toLowerCase()} before this page can be ready.`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
|
|
79
|
+
const loaded = await loadWorkspace(input);
|
|
80
|
+
const resourceType = requiredType(loaded, options.resourceType);
|
|
81
|
+
const assessment = assessCollectionReview(loaded, resourceType);
|
|
82
|
+
const allowedDecisions = assessment.configuration.decisions || ["complete"];
|
|
83
|
+
return {
|
|
84
|
+
resourceType,
|
|
85
|
+
decision: assessment.records.length
|
|
86
|
+
? "complete"
|
|
87
|
+
: allowedDecisions.includes("zero-population") ? "zero-population" : null,
|
|
88
|
+
rationale: null,
|
|
89
|
+
reviewedByIds: [],
|
|
90
|
+
reviewedOn: null,
|
|
91
|
+
authoritativeSystemId: null
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function planCollectionReview(input = process.cwd(), options = {}) {
|
|
96
|
+
const loaded = await loadWorkspace(input);
|
|
97
|
+
const resourceType = requiredType(loaded, options.resourceType);
|
|
98
|
+
const assessment = assessCollectionReview(loaded, resourceType);
|
|
99
|
+
const configuration = assessment.configuration;
|
|
100
|
+
const decision = String(options.decision || "").trim();
|
|
101
|
+
const rationale = String(options.rationale || "").trim();
|
|
102
|
+
const reviewedByIds = [...new Set((options.reviewedByIds || []).map(String).filter(Boolean))];
|
|
103
|
+
const reviewedOn = String(options.reviewedOn || "").trim();
|
|
104
|
+
const scopeRevision = String(options.scopeRevision || getGitSummary(loaded.root).commit || "uncommitted").trim();
|
|
105
|
+
const authoritativeSystemId = String(options.authoritativeSystemId || "").trim();
|
|
106
|
+
if (!(configuration.decisions || ["complete"]).includes(decision)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${configuration.title} review must use one of: ${(configuration.decisions || ["complete"]).join(", ")}.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (!assessment.records.length && decision === "complete") {
|
|
112
|
+
throw new Error(`${configuration.title} has no records. Use zero-population or another allowed conclusion.`);
|
|
113
|
+
}
|
|
114
|
+
if (assessment.records.length && decision === "zero-population") {
|
|
115
|
+
throw new Error(`${configuration.title} has ${assessment.records.length} records and cannot be confirmed as a zero population.`);
|
|
116
|
+
}
|
|
117
|
+
if (!rationale || !reviewedByIds.length || !reviewedOn) {
|
|
118
|
+
throw new Error(`${configuration.title} review needs review notes, a reviewer, and a review date.`);
|
|
119
|
+
}
|
|
120
|
+
if (decision === "externally-managed") {
|
|
121
|
+
const system = loaded.resources.find((record) => (
|
|
122
|
+
record.type === "system"
|
|
123
|
+
&& record.id === authoritativeSystemId
|
|
124
|
+
&& record.status === "active"
|
|
125
|
+
));
|
|
126
|
+
if (!system) throw new Error(`${configuration.title} review needs an active authoritative System.`);
|
|
127
|
+
}
|
|
128
|
+
const existing = assessment.review;
|
|
129
|
+
const record = {
|
|
130
|
+
...(existing || {
|
|
131
|
+
id: `collection-review-${resourceType}`,
|
|
132
|
+
type: "collection-review",
|
|
133
|
+
title: `${configuration.title} review`,
|
|
134
|
+
resourceType,
|
|
135
|
+
scopeResourceIds: [loaded.workspace.id]
|
|
136
|
+
}),
|
|
137
|
+
status: "active",
|
|
138
|
+
decision,
|
|
139
|
+
rationale,
|
|
140
|
+
reviewedByIds,
|
|
141
|
+
reviewedOn,
|
|
142
|
+
collectionRevision: assessment.collectionRevision,
|
|
143
|
+
scopeRevision,
|
|
144
|
+
...(decision === "externally-managed" ? { authoritativeSystemId } : {})
|
|
145
|
+
};
|
|
146
|
+
if (decision !== "externally-managed") delete record.authoritativeSystemId;
|
|
147
|
+
return {
|
|
148
|
+
operation: "collection-review",
|
|
149
|
+
resourceType,
|
|
150
|
+
assessment,
|
|
151
|
+
changes: {
|
|
152
|
+
...(existing ? { update: [record] } : { create: [record] }),
|
|
153
|
+
...(existing ? {
|
|
154
|
+
expectedRevisions: {
|
|
155
|
+
[existing.id]: options.expectedRevision || assessment.reviewRevision
|
|
156
|
+
}
|
|
157
|
+
} : {}),
|
|
158
|
+
validateWholeWorkspace: true
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function applyCollectionReview(input = process.cwd(), options = {}) {
|
|
164
|
+
if (options.confirmed !== true) {
|
|
165
|
+
throw new Error("Preview the collection review and confirm the write.");
|
|
166
|
+
}
|
|
167
|
+
const plan = await planCollectionReview(input, options);
|
|
168
|
+
const result = await applyResourceBatch(input, plan.changes);
|
|
169
|
+
const loaded = await loadWorkspace(input);
|
|
170
|
+
return {
|
|
171
|
+
...plan,
|
|
172
|
+
result,
|
|
173
|
+
assessment: assessCollectionReview(loaded, plan.resourceType)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requiredType(loaded, value) {
|
|
178
|
+
const resourceType = String(value || "").trim();
|
|
179
|
+
if (!loaded.model.collectionReviews?.[resourceType]) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Collection review type must be one of: ${Object.keys(loaded.model.collectionReviews || {}).join(", ")}.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return resourceType;
|
|
185
|
+
}
|
package/src/evidence-packet.js
CHANGED
|
@@ -884,6 +884,14 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
|
|
|
884
884
|
&& controlIdsForRecord(record, byId).has(controlId)
|
|
885
885
|
&& packetRecord(record, model, start, end, timezone)
|
|
886
886
|
));
|
|
887
|
+
const zeroPopulationRecords = records.filter((record) => (
|
|
888
|
+
record.type === "audit-population"
|
|
889
|
+
&& record.auditId === audit?.id
|
|
890
|
+
&& record.status === "reconciled"
|
|
891
|
+
&& record.conclusion === "complete"
|
|
892
|
+
&& (record.controlIds || []).includes(controlId)
|
|
893
|
+
&& byId.get(record.sourceEvidenceId)?.populationCount === 0
|
|
894
|
+
));
|
|
887
895
|
const linkedEvidenceIds = new Set(evidenceByControl.get(controlId) || []);
|
|
888
896
|
for (const test of tests) {
|
|
889
897
|
addIds(linkedEvidenceIds, test.evidenceIds);
|
|
@@ -909,7 +917,7 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
|
|
|
909
917
|
.map(({ id }) => id)
|
|
910
918
|
.sort(),
|
|
911
919
|
evidenceIds: [...linkedEvidenceIds].sort(),
|
|
912
|
-
operatingRecordIds: operatingRecords.map(({ id }) => id).sort(),
|
|
920
|
+
operatingRecordIds: [...operatingRecords, ...zeroPopulationRecords].map(({ id }) => id).sort(),
|
|
913
921
|
tests: tests.map((test) => ({
|
|
914
922
|
...testPopulationSummary(test, byId),
|
|
915
923
|
id: test.id,
|
|
@@ -935,6 +943,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
|
|
|
935
943
|
for (const sourceId of record.sourceResourceIds || []) addIds(ids, controlIdsForRecord(byId.get(sourceId), byId, seen));
|
|
936
944
|
if (record.sourceResourceId) addIds(ids, controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen));
|
|
937
945
|
if (record.obligationId) addIds(ids, byId.get(record.obligationId)?.controlIds);
|
|
946
|
+
for (const candidate of byId.values()) {
|
|
947
|
+
if (
|
|
948
|
+
candidate.type === "obligation"
|
|
949
|
+
&& (candidate.completionResourceIds || []).includes(record.id)
|
|
950
|
+
) {
|
|
951
|
+
addIds(ids, candidate.controlIds);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
for (const subjectId of record.subjectResourceIds || []) {
|
|
955
|
+
addIds(ids, controlIdsForRecord(byId.get(subjectId), byId, seen));
|
|
956
|
+
}
|
|
938
957
|
return ids;
|
|
939
958
|
}
|
|
940
959
|
|
|
@@ -1003,7 +1022,13 @@ function packetGaps({
|
|
|
1003
1022
|
} else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
|
|
1004
1023
|
gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
|
|
1005
1024
|
}
|
|
1006
|
-
if (
|
|
1025
|
+
if (
|
|
1026
|
+
audit?.auditKind !== "soc-2-type-1"
|
|
1027
|
+
&& coverage.status === "implemented"
|
|
1028
|
+
&& !coverage.operatingRecordIds.length
|
|
1029
|
+
&& !coverage.evidenceIds.length
|
|
1030
|
+
&& coverage.operationMode !== "automated"
|
|
1031
|
+
) {
|
|
1007
1032
|
gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
|
|
1008
1033
|
}
|
|
1009
1034
|
for (const test of coverage.tests) {
|
|
@@ -1151,6 +1176,13 @@ function packetGaps({
|
|
|
1151
1176
|
`${run.title}: ${action.title} was canceled. Complete the requirement or cancel the event with a documented reason.`,
|
|
1152
1177
|
action.actionItemId
|
|
1153
1178
|
));
|
|
1179
|
+
} else if (action.status === "blocked") {
|
|
1180
|
+
gaps.push(gap(
|
|
1181
|
+
"error",
|
|
1182
|
+
"blocked-event-action",
|
|
1183
|
+
`${run.title}: ${action.title} is blocked. ${action.blockingReason || "Open the Action Item and resolve its named blockers."}`,
|
|
1184
|
+
action.actionItemId
|
|
1185
|
+
));
|
|
1154
1186
|
} else if (action.missingCompletion) {
|
|
1155
1187
|
gaps.push(gap(
|
|
1156
1188
|
"error",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { applyResourceBatch } from "./files.js";
|
|
2
|
+
import { createResourceId } from "./id.js";
|
|
3
|
+
import { currentCalendarDate } from "./time.js";
|
|
4
|
+
import { loadWorkspace } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
|
|
7
|
+
const loaded = await loadWorkspace(input);
|
|
8
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
9
|
+
throw new Error("External reviewer setup requires a model v3 workspace.");
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
reviewerName: null,
|
|
13
|
+
jobTitle: null,
|
|
14
|
+
email: null,
|
|
15
|
+
organization: null,
|
|
16
|
+
startsOn: currentCalendarDate(loaded.workspace.timezone),
|
|
17
|
+
independenceRationale: null,
|
|
18
|
+
appointedByIds: [],
|
|
19
|
+
instructions: "Replace every null required value with current facts. The reviewer must be independent from policy ownership and operating work. Preview the completed file before applying it."
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
|
|
24
|
+
const loaded = await loadWorkspace(input);
|
|
25
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
26
|
+
throw new Error("External reviewer setup requires a model v3 workspace.");
|
|
27
|
+
}
|
|
28
|
+
const name = required(options.reviewerName, "External reviewer name");
|
|
29
|
+
const startsOn = required(options.startsOn, "Appointment start date");
|
|
30
|
+
const existingPerson = loaded.resources.find((record) => (
|
|
31
|
+
record.type === "person"
|
|
32
|
+
&& record.affiliation === "external"
|
|
33
|
+
&& (
|
|
34
|
+
record.id === options.reviewerId
|
|
35
|
+
|| record.email && options.email && record.email.toLowerCase() === String(options.email).toLowerCase()
|
|
36
|
+
)
|
|
37
|
+
));
|
|
38
|
+
const jobTitle = required(
|
|
39
|
+
options.jobTitle || existingPerson?.jobTitle,
|
|
40
|
+
"External reviewer organizational job title"
|
|
41
|
+
);
|
|
42
|
+
const person = {
|
|
43
|
+
...(existingPerson || {
|
|
44
|
+
id: options.reviewerId || createResourceId("person", name, loaded.resources.map(({ id }) => id)),
|
|
45
|
+
type: "person"
|
|
46
|
+
}),
|
|
47
|
+
title: name,
|
|
48
|
+
status: "active",
|
|
49
|
+
affiliation: "external",
|
|
50
|
+
jobTitle,
|
|
51
|
+
...(options.email ? { email: String(options.email) } : {}),
|
|
52
|
+
...(options.organization ? { organization: String(options.organization) } : {})
|
|
53
|
+
};
|
|
54
|
+
const workspaceId = loaded.workspace.id;
|
|
55
|
+
const creates = existingPerson ? [] : [person];
|
|
56
|
+
const updates = existingPerson ? [person] : [];
|
|
57
|
+
const appointmentIds = [];
|
|
58
|
+
const appointmentSpecs = [
|
|
59
|
+
{
|
|
60
|
+
kind: "independent-policy-reviewer",
|
|
61
|
+
title: "Independent Policy Reviewer",
|
|
62
|
+
responsibilities: "Review and approve policies and governed documents independently from the owner, chair security and risk oversight, and challenge management decisions."
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
for (const spec of appointmentSpecs) {
|
|
66
|
+
const existing = loaded.resources.find((record) => (
|
|
67
|
+
record.type === "appointment"
|
|
68
|
+
&& record.appointmentKind === spec.kind
|
|
69
|
+
&& record.status !== "ended"
|
|
70
|
+
));
|
|
71
|
+
const appointment = {
|
|
72
|
+
...(existing || {
|
|
73
|
+
id: createResourceId("appointment", spec.title, [
|
|
74
|
+
...loaded.resources.map(({ id }) => id),
|
|
75
|
+
...creates.map(({ id }) => id)
|
|
76
|
+
]),
|
|
77
|
+
type: "appointment",
|
|
78
|
+
title: spec.title
|
|
79
|
+
}),
|
|
80
|
+
status: "active",
|
|
81
|
+
appointmentKind: spec.kind,
|
|
82
|
+
holderId: person.id,
|
|
83
|
+
scopeResourceIds: [workspaceId],
|
|
84
|
+
startsOn,
|
|
85
|
+
responsibilities: existing?.responsibilities || spec.responsibilities,
|
|
86
|
+
independenceRationale: required(
|
|
87
|
+
options.independenceRationale,
|
|
88
|
+
"Independence rationale"
|
|
89
|
+
),
|
|
90
|
+
...(options.appointedByIds?.length
|
|
91
|
+
? { appointedByIds: [...new Set(options.appointedByIds.map(String))] }
|
|
92
|
+
: {})
|
|
93
|
+
};
|
|
94
|
+
appointmentIds.push(appointment.id);
|
|
95
|
+
if (existing) updates.push(appointment);
|
|
96
|
+
else creates.push(appointment);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const team = loaded.resources.find((record) => (
|
|
100
|
+
record.type === "team" && record.id === "team-security-risk-oversight"
|
|
101
|
+
)) || loaded.resources.find((record) => record.type === "team" && /oversight/i.test(record.title));
|
|
102
|
+
if (team) {
|
|
103
|
+
updates.push({
|
|
104
|
+
...team,
|
|
105
|
+
status: "active",
|
|
106
|
+
memberIds: [...new Set([...(team.memberIds || []), person.id])],
|
|
107
|
+
chairIds: [...new Set([...(team.chairIds || []), appointmentIds[0]])]
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
creates.push({
|
|
111
|
+
id: createResourceId("team", "Security and Risk Oversight", [
|
|
112
|
+
...loaded.resources.map(({ id }) => id),
|
|
113
|
+
...creates.map(({ id }) => id)
|
|
114
|
+
]),
|
|
115
|
+
type: "team",
|
|
116
|
+
title: "Security and Risk Oversight",
|
|
117
|
+
status: "active",
|
|
118
|
+
purpose: "Provide independent review of security, risk, policies, incidents, findings, and overdue work.",
|
|
119
|
+
memberIds: [person.id],
|
|
120
|
+
chairIds: [appointmentIds[0]]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const policy of loaded.resources.filter((record) => (
|
|
125
|
+
record.type === "policy"
|
|
126
|
+
&& ["draft", "in-review"].includes(record.status)
|
|
127
|
+
&& !(record.approverIds || []).includes(person.id)
|
|
128
|
+
))) {
|
|
129
|
+
updates.push({
|
|
130
|
+
...policy,
|
|
131
|
+
approverIds: [...new Set([...(policy.approverIds || []), person.id])]
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
operation: "external-reviewer-governance",
|
|
136
|
+
reviewerId: person.id,
|
|
137
|
+
appointmentIds,
|
|
138
|
+
changes: {
|
|
139
|
+
create: creates,
|
|
140
|
+
update: deduplicateUpdates(updates),
|
|
141
|
+
validateWholeWorkspace: true
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function setupExternalReviewerGovernance(input = process.cwd(), options = {}) {
|
|
147
|
+
if (options.confirmed !== true) {
|
|
148
|
+
throw new Error("Preview the external reviewer governance bundle and confirm the write.");
|
|
149
|
+
}
|
|
150
|
+
const plan = await planExternalReviewerGovernance(input, options);
|
|
151
|
+
const result = await applyResourceBatch(input, plan.changes);
|
|
152
|
+
return { ...plan, result };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function required(value, label) {
|
|
156
|
+
const normalized = String(value || "").trim();
|
|
157
|
+
if (!normalized) throw new Error(`${label} is required.`);
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function deduplicateUpdates(records) {
|
|
162
|
+
const byId = new Map();
|
|
163
|
+
for (const record of records) byId.set(record.id, record);
|
|
164
|
+
return [...byId.values()];
|
|
165
|
+
}
|
package/src/files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { getResourceDefinition } from "../model/index.js";
|
|
4
|
+
import { getResourceDefinition, loadModel } from "../model/index.js";
|
|
5
5
|
import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
|
|
6
6
|
import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
7
7
|
import { markdownEntries } from "./resource-markdown.js";
|
|
@@ -214,6 +214,31 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
214
214
|
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
215
215
|
}
|
|
216
216
|
const loaded = await loadWorkspace(input);
|
|
217
|
+
const workspaceUpdate = updates.find((record) => (
|
|
218
|
+
record.type === "workspace" && record.id === loaded.workspace?.id
|
|
219
|
+
));
|
|
220
|
+
const changesModelVersion = workspaceUpdate
|
|
221
|
+
&& String(workspaceUpdate.dataModelVersion || "") !== String(loaded.workspace?.dataModelVersion || "");
|
|
222
|
+
const targetModelVersion = changes.targetModelVersion
|
|
223
|
+
? String(changes.targetModelVersion)
|
|
224
|
+
: null;
|
|
225
|
+
if (changesModelVersion && !targetModelVersion) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"A resource batch that changes dataModelVersion must declare targetModelVersion."
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
targetModelVersion
|
|
232
|
+
&& (
|
|
233
|
+
changes.validateWholeWorkspace !== true
|
|
234
|
+
|| String(workspaceUpdate?.dataModelVersion || "") !== targetModelVersion
|
|
235
|
+
)
|
|
236
|
+
) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"A cross-model resource batch must validate the whole workspace and update its dataModelVersion to the target model."
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const writeModel = targetModelVersion ? loadModel(targetModelVersion) : loaded.model;
|
|
217
242
|
const deferValidation = workspaceValidationDeferred();
|
|
218
243
|
const before = deferValidation || changes.validateWholeWorkspace
|
|
219
244
|
? null
|
|
@@ -224,7 +249,7 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
224
249
|
for (const record of creates) {
|
|
225
250
|
validateBatchRecord(record, ids);
|
|
226
251
|
if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
|
|
227
|
-
const path = resourcePath(loaded.root,
|
|
252
|
+
const path = resourcePath(loaded.root, writeModel, record);
|
|
228
253
|
writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
|
|
229
254
|
}
|
|
230
255
|
for (const record of updates) {
|
|
@@ -234,7 +259,7 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
234
259
|
if (existing.record.type !== record.type) {
|
|
235
260
|
throw new Error(`Resource "${record.id}" cannot change type.`);
|
|
236
261
|
}
|
|
237
|
-
const path = resourcePath(loaded.root,
|
|
262
|
+
const path = resourcePath(loaded.root, writeModel, record);
|
|
238
263
|
const previous = await readFile(path, "utf8");
|
|
239
264
|
const mode = (await stat(path)).mode & 0o777;
|
|
240
265
|
assertRevision(
|
|
@@ -643,14 +668,15 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
|
|
|
643
668
|
if (record.type === "attestation") {
|
|
644
669
|
return prepareAttestationBinding(loaded, record, previousRecord);
|
|
645
670
|
}
|
|
646
|
-
|
|
671
|
+
const bindingField = approvalBindingField(record, loaded.model);
|
|
672
|
+
if (!bindingField) return record;
|
|
647
673
|
const nextRecord = structuredClone(record);
|
|
648
674
|
if (!approvalBound(record)) {
|
|
649
|
-
delete nextRecord
|
|
675
|
+
delete nextRecord[bindingField];
|
|
650
676
|
return nextRecord;
|
|
651
677
|
}
|
|
652
|
-
if (approvalBound(previousRecord) && previousRecord
|
|
653
|
-
nextRecord
|
|
678
|
+
if (approvalBound(previousRecord) && previousRecord[bindingField]) {
|
|
679
|
+
nextRecord[bindingField] = structuredClone(previousRecord[bindingField]);
|
|
654
680
|
return nextRecord;
|
|
655
681
|
}
|
|
656
682
|
const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
|
|
@@ -667,7 +693,7 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
|
|
|
667
693
|
}
|
|
668
694
|
revisions[item.path] = contentRevision(source);
|
|
669
695
|
}
|
|
670
|
-
nextRecord
|
|
696
|
+
nextRecord[bindingField] = revisions;
|
|
671
697
|
return nextRecord;
|
|
672
698
|
}
|
|
673
699
|
|
|
@@ -704,13 +730,23 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
|
|
|
704
730
|
}
|
|
705
731
|
|
|
706
732
|
function approvalBound(record) {
|
|
707
|
-
if (!record || !["policy", "document"].includes(record.type)) return false;
|
|
733
|
+
if (!record || !["policy", "document", "training"].includes(record.type)) return false;
|
|
708
734
|
const statuses = record.type === "policy"
|
|
709
735
|
? ["approved", "active", "superseded", "retired"]
|
|
710
|
-
:
|
|
736
|
+
: record.type === "document"
|
|
737
|
+
? ["active", "superseded", "retired"]
|
|
738
|
+
: ["active", "retired"];
|
|
711
739
|
return statuses.includes(record.status);
|
|
712
740
|
}
|
|
713
741
|
|
|
742
|
+
function approvalBindingField(record, model) {
|
|
743
|
+
if (["policy", "document"].includes(record?.type)) return "approvedContentRevisions";
|
|
744
|
+
if (record?.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
745
|
+
return "effectiveContentRevisions";
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
714
750
|
async function exclusiveContentFiles(loaded, record) {
|
|
715
751
|
const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
|
|
716
752
|
const files = [];
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
ACTIVE_MODEL_VERSION,
|
|
3
|
+
getResourceDefinition,
|
|
4
|
+
loadModel,
|
|
5
|
+
SUPPORTED_MODEL_VERSIONS
|
|
6
|
+
} from "../model/index.js";
|
|
2
7
|
export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
|
|
3
8
|
export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
9
|
+
export { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
10
|
+
export {
|
|
11
|
+
applyApplicabilityReview,
|
|
12
|
+
planApplicabilityReview,
|
|
13
|
+
scaffoldApplicabilityReview
|
|
14
|
+
} from "./batch-review.js";
|
|
15
|
+
export {
|
|
16
|
+
applyCollectionReview,
|
|
17
|
+
assessCollectionReview,
|
|
18
|
+
assessCollectionReviews,
|
|
19
|
+
collectionRevision,
|
|
20
|
+
planCollectionReview,
|
|
21
|
+
scaffoldCollectionReview
|
|
22
|
+
} from "./collection-review.js";
|
|
4
23
|
export { buildWorkspace } from "./build.js";
|
|
5
24
|
export { generateEvidencePacket, prepareEvidencePacket, writeEvidencePacket } from "./evidence-packet.js";
|
|
6
25
|
export {
|
|
@@ -35,13 +54,20 @@ export {
|
|
|
35
54
|
completeObligationEvent,
|
|
36
55
|
completeObligationOccurrence,
|
|
37
56
|
createObligationEvent,
|
|
38
|
-
planObligations
|
|
57
|
+
planObligations,
|
|
58
|
+
scaffoldObligationCompletion
|
|
39
59
|
} from "./obligations.js";
|
|
60
|
+
export {
|
|
61
|
+
planExternalReviewerGovernance,
|
|
62
|
+
scaffoldExternalReviewerGovernance,
|
|
63
|
+
setupExternalReviewerGovernance
|
|
64
|
+
} from "./external-reviewer.js";
|
|
40
65
|
export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
41
66
|
export {
|
|
42
67
|
buildAgentProgramPath,
|
|
43
68
|
PROGRAM_PATH,
|
|
44
69
|
RESOURCE_INSTRUCTIONS,
|
|
70
|
+
RESOURCE_PAGE_SUMMARIES,
|
|
45
71
|
resourceProgramContext
|
|
46
72
|
} from "./program-path.js";
|
|
47
73
|
export {
|
|
@@ -53,9 +79,17 @@ export {
|
|
|
53
79
|
} from "./recurrence.js";
|
|
54
80
|
export { searchResources, searchableValues } from "./search.js";
|
|
55
81
|
export { effectiveResourceStatus } from "./resource-status.js";
|
|
82
|
+
export { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
56
83
|
export { createFilegrcServer, serveWorkspace } from "./server.js";
|
|
57
84
|
export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
|
|
58
85
|
export { createAppState, createResourceDetail } from "./state.js";
|
|
59
86
|
export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
60
87
|
export { validateWorkspace } from "./validate.js";
|
|
88
|
+
export {
|
|
89
|
+
assessWorkflow,
|
|
90
|
+
buildWorkflowDelta,
|
|
91
|
+
previewWorkflowMutation,
|
|
92
|
+
workflowForResource,
|
|
93
|
+
WORKFLOW_CONTRACT_VERSION
|
|
94
|
+
} from "./workflow.js";
|
|
61
95
|
export { indexResources, loadWorkspace } from "./workspace.js";
|
package/src/model-docs.js
CHANGED
|
@@ -45,6 +45,16 @@ export function generateModelDocumentation(model) {
|
|
|
45
45
|
`| \`${name}\` | ${types.map((type) => `\`${type}\``).join(", ")} |`
|
|
46
46
|
)),
|
|
47
47
|
"",
|
|
48
|
+
"## Collection review confirmations",
|
|
49
|
+
"",
|
|
50
|
+
"FileGRC derives record issues, but it cannot infer that management reviewed an apparently complete or empty collection. Each configured collection review records the conclusion, reviewer, date, current scope revision, and exact collection revision. A record or material scope change makes the confirmation stale.",
|
|
51
|
+
"",
|
|
52
|
+
"| Resource type | Review | Allowed conclusions | What to review |",
|
|
53
|
+
"| --- | --- | --- | --- |",
|
|
54
|
+
...Object.entries(model.collectionReviews || {}).map(([type, review]) => (
|
|
55
|
+
`| \`${type}\` | ${escapeCell(review.title)} | ${review.decisions.map((decision) => `\`${decision}\``).join(", ")} | ${escapeCell(review.reviewPoints.join(" "))} |`
|
|
56
|
+
)),
|
|
57
|
+
"",
|
|
48
58
|
"## Relationship constraints",
|
|
49
59
|
"",
|
|
50
60
|
"Relationship constraints prevent cycles and duplicate active authority or access records.",
|
|
@@ -130,6 +140,11 @@ export function generateModelDocumentation(model) {
|
|
|
130
140
|
lines.push(`Instructions: ${RESOURCE_INSTRUCTIONS[type] || resource.description}`, "");
|
|
131
141
|
lines.push(`Policy basis: ${resource.guidance.policyBasis}`, "");
|
|
132
142
|
lines.push(`Timing: ${resource.guidance.cadence}`, "");
|
|
143
|
+
if (resource.guidance.reviewPoints?.length) {
|
|
144
|
+
lines.push("When reviewing:", "");
|
|
145
|
+
for (const point of resource.guidance.reviewPoints) lines.push(`- ${point}`);
|
|
146
|
+
lines.push("");
|
|
147
|
+
}
|
|
133
148
|
if (resource.guidance.sourceResourceIds?.length) {
|
|
134
149
|
lines.push(`Default sources: ${resource.guidance.sourceResourceIds.map((id) => `\`${id}\``).join(", ")}`, "");
|
|
135
150
|
}
|