filegrc 0.4.0 → 0.5.1

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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/Sunpeak-AI/filegrc.git"
8
+ "url": "git+https://github.com/Alignbase/filegrc.git"
9
9
  },
10
10
  "type": "module",
11
11
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "src"
22
22
  ],
23
23
  "scripts": {
24
- "test": "node --test"
24
+ "test": "node --test --test-concurrency=1"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=20"
package/src/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createResourceId } from "./id.js";
2
2
  import { markdownEntries } from "./resource-markdown.js";
3
3
  import { RESOURCE_INSTRUCTIONS, resourceProgramContext } from "./program-path.js";
4
+ import { assessCollectionReview } from "./collection-review.js";
4
5
 
5
6
  const STARTING_STATUS_ORDER = [
6
7
  "draft",
@@ -28,6 +29,7 @@ export function listResourceTypes(model) {
28
29
  export function buildAgentGuide(loaded, type, options = {}) {
29
30
  const definition = loaded.model.resources[type];
30
31
  if (!definition) throw new Error(`Unknown resource type "${type}".`);
32
+ const collectionReview = assessCollectionReview(loaded, type);
31
33
  const fields = { ...loaded.model.commonFields, ...definition.fields };
32
34
  const required = new Set([
33
35
  ...Object.entries(loaded.model.commonFields)
@@ -81,8 +83,23 @@ export function buildAgentGuide(loaded, type, options = {}) {
81
83
  programStep: resourceProgramContext(type),
82
84
  policyBasis: definition.guidance.policyBasis,
83
85
  cadence: definition.guidance.cadence,
86
+ emptyState: definition.guidance.emptyState ?? null,
84
87
  policySourceIds: definition.guidance.sourceResourceIds ?? [],
85
88
  obligationActivityTypes: definition.guidance.obligationActivityTypes ?? [],
89
+ reviewRequirements: {
90
+ recordReviewPoints: definition.guidance.reviewPoints ?? [],
91
+ collectionReview: collectionReview
92
+ ? {
93
+ title: collectionReview.configuration.title,
94
+ description: collectionReview.configuration.description,
95
+ reviewPoints: collectionReview.configuration.reviewPoints,
96
+ allowedDecisions: collectionReview.configuration.decisions,
97
+ status: collectionReview.status,
98
+ recordCount: collectionReview.recordCount,
99
+ command: `npx filegrc review-collection ${type} --scaffold`
100
+ }
101
+ : null
102
+ },
86
103
  location,
87
104
  singleton: Boolean(definition.singleton),
88
105
  requiredAtCreation,
@@ -155,6 +172,7 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
155
172
  record[name] = scaffoldValue(name, field, loaded.model);
156
173
  }
157
174
  }
175
+ applyModelScaffoldDefaults(record, loaded);
158
176
 
159
177
  const slots = markdownEntries(loaded.model, record).filter((slot) => (
160
178
  slot.required
@@ -171,6 +189,41 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
171
189
  };
172
190
  }
173
191
 
192
+ function applyModelScaffoldDefaults(record, loaded) {
193
+ if (record.type === "appointment") {
194
+ const normalizedTitle = record.title.toLowerCase();
195
+ const match = Object.entries(loaded.model.appointmentTemplates || {}).find(([kind, template]) => (
196
+ template.title.toLowerCase() === normalizedTitle
197
+ || kind === normalizedTitle.replace(/[^a-z0-9]+/g, "-")
198
+ ));
199
+ record.appointmentKind = match?.[0] || normalizedTitle.replace(/[^a-z0-9]+/g, "-");
200
+ if (!record.scopeResourceIds?.length && loaded.workspace?.id) {
201
+ record.scopeResourceIds = [loaded.workspace.id];
202
+ }
203
+ return;
204
+ }
205
+ if (record.type === "audit") {
206
+ const kind = {
207
+ "soc-2-type-1": "soc-2-type-1",
208
+ "soc-2-type-2": "soc-2-type-2"
209
+ }[loaded.workspace?.assuranceGoal];
210
+ if (kind) record.auditKind = kind;
211
+ for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
212
+ if (loaded.workspace?.[field]?.length) record[field] = [...loaded.workspace[field]];
213
+ }
214
+ const programOwner = loaded.resources.find((candidate) => (
215
+ candidate.type === "appointment"
216
+ && candidate.appointmentKind === "program-lead"
217
+ && candidate.status === "active"
218
+ )) || loaded.resources.find((candidate) => (
219
+ candidate.type === "appointment"
220
+ && candidate.appointmentKind === "policy-owner"
221
+ && candidate.status === "active"
222
+ ));
223
+ if (programOwner) record.ownerIds = [programOwner.id];
224
+ }
225
+ }
226
+
174
227
  export function findResourceReferences(loaded, id) {
175
228
  const target = loaded.resources.find((record) => record.id === id);
176
229
  if (!target) throw new Error(`Resource "${id}" was not found.`);
@@ -0,0 +1,19 @@
1
+ export function assessRequiredAppointments(records, model) {
2
+ const templates = model.appointmentTemplates || {};
3
+ return Object.entries(templates).map(([kind, template]) => {
4
+ const appointments = records.filter((record) => (
5
+ record.type === "appointment"
6
+ && record.appointmentKind === kind
7
+ && record.status !== "ended"
8
+ ));
9
+ const active = appointments.find(({ status }) => status === "active");
10
+ const planned = appointments.find(({ status }) => status === "planned");
11
+ return {
12
+ kind,
13
+ template,
14
+ requiredness: template.requiredness,
15
+ record: active || planned || null,
16
+ state: active ? "complete" : "ready"
17
+ };
18
+ });
19
+ }
@@ -10,7 +10,7 @@ import {
10
10
  } from "./coverage.js";
11
11
  import { createResource, createResources, deleteResource, updateResource } from "./files.js";
12
12
  import { createResourceId } from "./id.js";
13
- import { partiesIndependent } from "./parties.js";
13
+ import { currentPartyPeople, partiesIndependent } from "./parties.js";
14
14
  import { resolveDataPath } from "./paths.js";
15
15
  import { assessProgramReadiness } from "./program-readiness.js";
16
16
  import { markdownEntries } from "./resource-markdown.js";
@@ -433,7 +433,10 @@ function engagementStage(audit, byId, programReadiness) {
433
433
  }
434
434
  const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
435
435
  const named = Boolean(auditor);
436
- return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and engagement contacts before treating the audit as active.", [
436
+ const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
437
+ .map((id) => byId.get(id))
438
+ .filter(Boolean);
439
+ return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", [
437
440
  item(
438
441
  "engagement-record",
439
442
  "complete",
@@ -449,6 +452,21 @@ function engagementStage(audit, byId, programReadiness) {
449
452
  ? `${auditor.title} is recorded for the engagement.`
450
453
  : "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
451
454
  audit
455
+ ),
456
+ item(
457
+ "engagement-owner",
458
+ currentOwners.length ? "complete" : "action",
459
+ "Confirm the management engagement owner",
460
+ currentOwners.length
461
+ ? `${currentOwners.map(({ title }) => title).join(" and ")} currently owns management coordination for the engagement.`
462
+ : "Assign the audit to a current Person, Team, or Appointment. The audit owner may coordinate management and evidence work without a separate audit-specific title.",
463
+ audit,
464
+ {
465
+ commands: [
466
+ `npx filegrc get ${audit.id} --mutation`,
467
+ "npx filegrc audit-readiness AUDIT_ID --json"
468
+ ]
469
+ }
452
470
  )
453
471
  ]);
454
472
  }
@@ -556,12 +574,21 @@ function evidenceStage(audit, records, byId, model) {
556
574
  && controlIdsForRecord(record, byId).size
557
575
  && recordRelevantToAuditDate(record, audit, model)
558
576
  ));
577
+ const reconciledZeroPopulationControlIds = new Set(records
578
+ .filter((record) => (
579
+ record.type === "audit-population"
580
+ && record.auditId === audit.id
581
+ && record.status === "reconciled"
582
+ && record.conclusion === "complete"
583
+ && byId.get(record.sourceEvidenceId)?.populationCount === 0
584
+ ))
585
+ .flatMap((record) => record.controlIds || []));
559
586
  const managedControls = controls.filter((control) => managedFamilies.some((family) => (
560
587
  (family.controlCodes || []).includes(control.code)
561
588
  )));
562
589
  const controlsWithFilegrcRecords = managedControls.filter((control) => filegrcRecords.some((record) => (
563
590
  controlIdsForRecord(record, byId).has(control.id)
564
- )));
591
+ )) || reconciledZeroPopulationControlIds.has(control.id));
565
592
  const externalControls = controls.filter((control) => externalFamilies.some((family) => (
566
593
  (family.controlCodes || []).includes(control.code)
567
594
  )) || !evidenceFamiliesFor(control).length);
@@ -574,7 +601,7 @@ function evidenceStage(audit, records, byId, model) {
574
601
  managedControls.length && controlsWithFilegrcRecords.length === managedControls.length ? "complete" : managedControls.length ? "action" : "info",
575
602
  "Review filegrc Evidence",
576
603
  managedControls.length
577
- ? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record for the formal period. Complete each Step 4 record, link it to the control, and add results in its structured fields or Markdown.`
604
+ ? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record or reconciled zero-event population for the formal period. Complete each Step 4 record, link it to the control, and add results in its structured fields or Markdown.`
578
605
  : "No selected controls use a dedicated filegrc operating record.",
579
606
  filegrcRecords[0] || { type: managedFamilies[0]?.operationRecordTypes?.[0] || "control" }
580
607
  ),
@@ -606,13 +633,16 @@ function evidenceStage(audit, records, byId, model) {
606
633
  ));
607
634
  const coveredControls = relevantControls.filter((control) => sourceRecords.some((record) => (
608
635
  controlIdsForRecord(record, byId).has(control.id)
609
- )));
636
+ )) || reconciledZeroPopulationControlIds.has(control.id));
637
+ const zeroPopulationControls = relevantControls.filter((control) => (
638
+ reconciledZeroPopulationControlIds.has(control.id)
639
+ ));
610
640
  items.push(item(
611
641
  `filegrc-${source.id}`,
612
642
  coveredControls.length === relevantControls.length ? "complete" : "action",
613
643
  source.title,
614
644
  coveredControls.length === relevantControls.length
615
- ? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} cover ${relevantControls.length} mapped controls. External artifacts needed to support those results are linked from the operating records.`
645
+ ? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} and ${zeroPopulationControls.length} reconciled zero-population ${zeroPopulationControls.length === 1 ? "conclusion cover" : "conclusions cover"} ${relevantControls.length} mapped controls. Supporting artifacts are linked from the operating records or population export.`
616
646
  : `${coveredControls.length} of ${relevantControls.length} mapped controls have a dated ${source.operationRecordTypes.map(displayValue).join(" or ")} record for the formal period. Complete the Step 4 work and attach or reference any supporting external artifact on that record.`,
617
647
  sourceRecords[0] || { type: source.operationRecordTypes[0] }
618
648
  ));
@@ -846,6 +876,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
846
876
  if (record.obligationId) {
847
877
  for (const id of byId.get(record.obligationId)?.controlIds || []) ids.add(id);
848
878
  }
879
+ for (const candidate of byId.values()) {
880
+ if (
881
+ candidate.type === "obligation"
882
+ && (candidate.completionResourceIds || []).includes(record.id)
883
+ ) {
884
+ for (const id of candidate.controlIds || []) ids.add(id);
885
+ }
886
+ }
887
+ for (const subjectId of record.subjectResourceIds || []) {
888
+ for (const id of controlIdsForRecord(byId.get(subjectId), byId, seen)) ids.add(id);
889
+ }
849
890
  for (const sourceId of record.sourceResourceIds || []) {
850
891
  for (const id of controlIdsForRecord(byId.get(sourceId), byId, seen)) ids.add(id);
851
892
  }
@@ -0,0 +1,96 @@
1
+ import { createResource } from "./files.js";
2
+ import { createResourceId } from "./id.js";
3
+ import { loadWorkspace } from "./workspace.js";
4
+
5
+ export async function planNextAuditCycle(input = process.cwd(), options = {}) {
6
+ const loaded = await loadWorkspace(input);
7
+ if (String(loaded.model.modelVersion) !== "3") {
8
+ throw new Error("Audit-cycle carry-forward requires a model v3 workspace.");
9
+ }
10
+ const prior = loaded.resources.find((record) => (
11
+ record.type === "audit" && record.id === options.priorAuditId
12
+ ));
13
+ if (!prior) throw new Error("A prior Audit record is required.");
14
+ const startsOn = required(options.startsOn, "Next period start");
15
+ const endsOn = required(options.endsOn, "Next period end");
16
+ if (startsOn > endsOn) throw new Error("The next period end must be on or after its start.");
17
+ const priorEnd = prior.coverage?.endsOn || prior.coverage?.on;
18
+ if (prior.auditKind === "soc-2-type-1" && priorEnd && startsOn <= priorEnd) {
19
+ throw new Error(`A Type 2 operating period must start after the Type 1 as-of date ${priorEnd}.`);
20
+ }
21
+ const title = String(options.title || nextTitle(prior, startsOn, endsOn)).trim();
22
+ const audit = {
23
+ id: options.id || createResourceId("audit", title, loaded.resources.map(({ id }) => id)),
24
+ type: "audit",
25
+ title,
26
+ status: "planned",
27
+ auditKind: "soc-2-type-2",
28
+ priorAuditId: prior.id,
29
+ coverage: { kind: "range", startsOn, endsOn },
30
+ frameworkIds: [...(prior.frameworkIds || [])],
31
+ systemIds: [...(prior.systemIds || [])],
32
+ requirementIds: [...(prior.requirementIds || [])],
33
+ controlIds: [...(prior.controlIds || [])],
34
+ complementaryControlIds: [...(prior.complementaryControlIds || [])],
35
+ subserviceVendorIds: [...(prior.subserviceVendorIds || [])],
36
+ ...(prior.subserviceMethod ? { subserviceMethod: prior.subserviceMethod } : {}),
37
+ ...(prior.complementaryControlsConclusion
38
+ ? { complementaryControlsConclusion: prior.complementaryControlsConclusion }
39
+ : {}),
40
+ ...(prior.auditorVendorId ? { auditorVendorId: prior.auditorVendorId } : {}),
41
+ contactIds: [...(prior.contactIds || [])],
42
+ ownerIds: [...(prior.ownerIds || [])],
43
+ signatoryAppointmentIds: [...(prior.signatoryAppointmentIds || [])],
44
+ scope: String(options.scope || prior.scope || "").trim(),
45
+ ...(String(options.scopeRevision || "").trim()
46
+ ? { scopeRevision: String(options.scopeRevision).trim() }
47
+ : {})
48
+ };
49
+ return {
50
+ operation: prior.auditKind === "soc-2-type-1" ? "type-1-to-type-2" : "next-audit-cycle",
51
+ priorAuditId: prior.id,
52
+ audit,
53
+ carriedForward: [
54
+ "frameworkIds",
55
+ "systemIds",
56
+ "requirementIds",
57
+ "controlIds",
58
+ "complementaryControlIds",
59
+ "subserviceVendorIds",
60
+ "subserviceMethod",
61
+ "auditorVendorId",
62
+ "contactIds",
63
+ "ownerIds",
64
+ "signatoryAppointmentIds",
65
+ "scope"
66
+ ],
67
+ reviewRequired: [
68
+ "coverage",
69
+ "scopeRevision",
70
+ "criteria and control changes since the prior audit",
71
+ "source and policy continuity",
72
+ "subservice assurance coverage",
73
+ "new or changed commitments"
74
+ ]
75
+ };
76
+ }
77
+
78
+ export async function createNextAuditCycle(input = process.cwd(), options = {}) {
79
+ if (options.confirmed !== true) {
80
+ throw new Error("Preview the next audit cycle and confirm the write.");
81
+ }
82
+ const plan = await planNextAuditCycle(input, options);
83
+ const result = await createResource(input, plan.audit);
84
+ return { ...plan, result };
85
+ }
86
+
87
+ function required(value, label) {
88
+ const normalized = String(value || "").trim();
89
+ if (!normalized) throw new Error(`${label} is required.`);
90
+ return normalized;
91
+ }
92
+
93
+ function nextTitle(prior, startsOn, endsOn) {
94
+ const year = endsOn.slice(0, 4) || startsOn.slice(0, 4);
95
+ return `${year} SOC 2 Type 2 audit after ${prior.title}`;
96
+ }
@@ -0,0 +1,109 @@
1
+ import { applyResourceBatch } from "./files.js";
2
+ import { getGitSummary } from "./git.js";
3
+ import { loadWorkspace } from "./workspace.js";
4
+
5
+ const REVIEWABLE_TYPES = new Set([
6
+ "requirement",
7
+ "control",
8
+ "commitment",
9
+ "complementary-control"
10
+ ]);
11
+
12
+ export async function scaffoldApplicabilityReview(input = process.cwd(), options = {}) {
13
+ const loaded = await loadWorkspace(input);
14
+ if (String(loaded.model.modelVersion) !== "3") {
15
+ throw new Error("Batch applicability review requires a model v3 workspace.");
16
+ }
17
+ const requestedType = options.type ? String(options.type) : null;
18
+ if (requestedType && !REVIEWABLE_TYPES.has(requestedType)) {
19
+ throw new Error(`Applicability review type must be one of ${[...REVIEWABLE_TYPES].join(", ")}.`);
20
+ }
21
+ const records = loaded.resources.filter((record) => (
22
+ REVIEWABLE_TYPES.has(record.type)
23
+ && (!requestedType || record.type === requestedType)
24
+ && !record.applicabilityReview
25
+ && !["retired", "superseded"].includes(record.status)
26
+ ));
27
+ return {
28
+ reviewedByIds: [],
29
+ reviewedOn: null,
30
+ decisions: records
31
+ .sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
32
+ .map((record) => ({
33
+ id: record.id,
34
+ decision: null,
35
+ rationale: null
36
+ }))
37
+ };
38
+ }
39
+
40
+ export async function planApplicabilityReview(input = process.cwd(), options = {}) {
41
+ const loaded = await loadWorkspace(input);
42
+ if (String(loaded.model.modelVersion) !== "3") {
43
+ throw new Error("Batch applicability review requires a model v3 workspace.");
44
+ }
45
+ if (!Array.isArray(options.decisions) || !options.decisions.length) {
46
+ throw new Error("Applicability review needs at least one decision.");
47
+ }
48
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
49
+ const update = options.decisions.map((decision) => {
50
+ const record = byId.get(decision.id);
51
+ if (!record || !REVIEWABLE_TYPES.has(record.type)) {
52
+ throw new Error(`Resource "${decision.id}" is not an applicability-review record.`);
53
+ }
54
+ const reviewedByIds = [...new Set((decision.reviewedByIds || options.reviewedByIds || []).map(String))];
55
+ const reviewedOn = String(decision.reviewedOn || options.reviewedOn || "").trim();
56
+ const scopeRevision = String(
57
+ decision.scopeRevision
58
+ || options.scopeRevision
59
+ || getGitSummary(loaded.root).commit
60
+ || "uncommitted"
61
+ ).trim();
62
+ const rationale = String(decision.rationale || "").trim();
63
+ const result = String(decision.decision || "").trim();
64
+ if (!["applicable", "not-applicable", "externally-managed", "zero-population"].includes(result)) {
65
+ throw new Error(`Decision for "${record.id}" must be applicable, not-applicable, externally-managed, or zero-population.`);
66
+ }
67
+ if (!reviewedByIds.length || !reviewedOn || !rationale) {
68
+ throw new Error(`Decision for "${record.id}" needs a reviewer, review date, and rationale.`);
69
+ }
70
+ const next = {
71
+ ...record,
72
+ applicabilityReview: {
73
+ decision: result,
74
+ rationale,
75
+ reviewedByIds,
76
+ reviewedOn,
77
+ scopeRevision
78
+ }
79
+ };
80
+ if (record.type === "requirement") {
81
+ if (!["applicable", "not-applicable"].includes(result)) {
82
+ throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
83
+ }
84
+ next.applicability = result;
85
+ next.applicabilityRationale = rationale;
86
+ }
87
+ if (record.type === "control" && result === "not-applicable") next.status = "not-applicable";
88
+ if (record.type === "control" && result === "applicable" && record.status === "not-applicable") next.status = "planned";
89
+ return next;
90
+ });
91
+ return {
92
+ operation: "applicability-review",
93
+ reviewedIds: update.map(({ id }) => id),
94
+ changes: {
95
+ update,
96
+ expectedRevisions: options.expectedRevisions || {},
97
+ validateWholeWorkspace: true
98
+ }
99
+ };
100
+ }
101
+
102
+ export async function applyApplicabilityReview(input = process.cwd(), options = {}) {
103
+ if (options.confirmed !== true) {
104
+ throw new Error("Preview the applicability decisions and confirm the write.");
105
+ }
106
+ const plan = await planApplicabilityReview(input, options);
107
+ const result = await applyResourceBatch(input, plan.changes);
108
+ return { ...plan, result };
109
+ }