Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions enterprise-training-policy-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Enterprise Training Policy Guard

This module adds a focused Enterprise Tooling guard for training currency and policy acknowledgement evidence before privileged exports or project actions continue.

It evaluates synthetic institutional access packets for:

- missing required policy acknowledgements
- stale policy acknowledgements older than 180 days
- missing role-specific training modules
- expired training certificates
- missing receipt IDs for completed training

The output is deterministic and reviewer-ready: `release`, `revise`, or `hold`, plus remediation text for each finding.

## Run

```bash
npm test
npm run demo
npm run demo:video
```

Generated artifacts are written to `reports/`.

## Safety

The sample packets are synthetic. The module does not call external services and does not use credentials, private research records, payment systems, wallets, live SSO/SCIM systems, or production exports.
60 changes: 60 additions & 0 deletions enterprise-training-policy-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const fs = require("node:fs");
const path = require("node:path");
const { cleanPacket, riskyPacket } = require("./sample-data");
const { evaluateTrainingPolicyPacket } = require("./index");

const reportsDir = path.join(__dirname, "reports");
fs.mkdirSync(reportsDir, { recursive: true });

const release = evaluateTrainingPolicyPacket(cleanPacket);
const hold = evaluateTrainingPolicyPacket(riskyPacket);
const combined = { generatedAt: new Date().toISOString(), scenarios: [release, hold] };

fs.writeFileSync(
path.join(reportsDir, "training-policy-report.json"),
JSON.stringify(combined, null, 2)
);

const markdown = [
"# Enterprise Training Policy Guard Demo",
"",
`Generated: ${combined.generatedAt}`,
"",
"| Scenario | Decision | Blockers | Revisions | Summary |",
"| --- | --- | ---: | ---: | --- |",
...combined.scenarios.map((item) =>
`| ${item.actionId} | ${item.decision} | ${item.blockerCount} | ${item.revisionCount} | ${item.reviewerSummary} |`
),
"",
"## Hold Findings",
"",
...hold.findings.map((item) =>
`- **${item.code}** (${item.severity}) for ${item.researcherId}: ${item.message} ${item.remediation}`
)
].join("\n");

fs.writeFileSync(path.join(reportsDir, "training-policy-report.md"), markdown);

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="520" viewBox="0 0 900 520">
<rect width="900" height="520" fill="#0f172a"/>
<rect x="48" y="44" width="804" height="432" rx="18" fill="#f8fafc"/>
<text x="82" y="98" font-family="Arial" font-size="34" font-weight="700" fill="#0f172a">Enterprise Training Policy Guard</text>
<text x="82" y="142" font-family="Arial" font-size="18" fill="#334155">Stops privileged exports when training, policy acknowledgement, or evidence receipts are stale.</text>
<rect x="82" y="190" width="330" height="190" rx="14" fill="#dcfce7" stroke="#16a34a"/>
<text x="112" y="240" font-family="Arial" font-size="26" font-weight="700" fill="#166534">Release</text>
<text x="112" y="282" font-family="Arial" font-size="18" fill="#166534">2 researchers checked</text>
<text x="112" y="316" font-family="Arial" font-size="18" fill="#166534">0 blockers / 0 revisions</text>
<rect x="488" y="190" width="330" height="190" rx="14" fill="#fee2e2" stroke="#dc2626"/>
<text x="518" y="240" font-family="Arial" font-size="26" font-weight="700" fill="#991b1b">Hold</text>
<text x="518" y="282" font-family="Arial" font-size="18" fill="#991b1b">4 blockers / 2 revisions</text>
<text x="518" y="316" font-family="Arial" font-size="18" fill="#991b1b">Missing, expired, and stale evidence</text>
<text x="82" y="430" font-family="Arial" font-size="16" fill="#475569">Synthetic data only. No live credentials, payment systems, or private research records are used.</text>
</svg>`;

fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg);
fs.writeFileSync(
path.join(reportsDir, "demo-script.txt"),
"Show the release scenario, then the restricted export hold scenario, then the remediation list."
);

console.log(JSON.stringify(combined, null, 2));
88 changes: 88 additions & 0 deletions enterprise-training-policy-guard/demo_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import os
from pathlib import Path
import subprocess

ROOT = Path(__file__).resolve().parent
REPORTS = ROOT / "reports"
FRAMES = REPORTS / "frames"

REPORTS.mkdir(exist_ok=True)
FRAMES.mkdir(exist_ok=True)

COLORS = [
("RELEASE", "0x16a34a", "All training and acknowledgements current"),
("REVISE", "0xca8a04", "Receipt and acknowledgement gaps detected"),
("HOLD", "0xdc2626", "Expired training blocks privileged export"),
]

gif_path = REPORTS / "demo.gif"
mp4_path = REPORTS / "demo.mp4"

ffmpeg = os.environ.get("FFMPEG")
if not ffmpeg:
candidates = list((ROOT.parents[2] / "tools" / "video-gen").glob("node_modules/**/ffmpeg.exe"))
ffmpeg = str(candidates[0]) if candidates else "ffmpeg"

font = "C\\:/Windows/Fonts/arial.ttf"

for index, (label, color, subtitle) in enumerate(COLORS):
frame_path = FRAMES / f"frame-{index:03d}.png"
filters = (
"drawbox=x=60:y=56:w=780:h=408:color=0xf8fafc:t=fill,"
"drawtext=fontfile='{font}':text='Enterprise Training Policy Guard':x=104:y=118:fontsize=34:fontcolor=0x0f172a,"
"drawtext=fontfile='{font}':text='Decision\\: {label}':x=104:y=190:fontsize=30:fontcolor={color},"
"drawtext=fontfile='{font}':text='{subtitle}':x=104:y=248:fontsize=22:fontcolor=0x334155,"
"drawbox=x=104:y=318:w={bar}:h=54:color={color}:t=fill,"
"drawtext=fontfile='{font}':text='Synthetic data only - reviewer-ready findings':x=104:y=414:fontsize=18:fontcolor=0x475569"
).format(font=font, label=label, subtitle=subtitle, color=color, bar=240 + index * 120)
subprocess.run(
[
ffmpeg,
"-y",
"-f",
"lavfi",
"-i",
"color=c=0x0f172a:s=900x520:d=1",
"-vf",
filters,
"-frames:v",
"1",
str(frame_path),
],
check=True,
)

subprocess.run(
[
ffmpeg,
"-y",
"-framerate",
"1",
"-i",
str(FRAMES / "frame-%03d.png"),
"-vf",
"scale=900:520:flags=lanczos",
str(gif_path),
],
check=True,
)

subprocess.run(
[
ffmpeg,
"-y",
"-framerate",
"1",
"-i",
str(FRAMES / "frame-%03d.png"),
"-vf",
"scale=900:520:flags=lanczos,format=yuv420p",
"-movflags",
"+faststart",
str(mp4_path),
],
check=True,
)

print(f"wrote {gif_path}")
print(f"wrote {mp4_path}")
145 changes: 145 additions & 0 deletions enterprise-training-policy-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
const DAY_MS = 24 * 60 * 60 * 1000;

const ROLE_TRAINING = {
principal_investigator: ["human-subjects", "secure-export", "ai-governance"],
data_steward: ["secure-export", "ai-governance"],
external_collaborator: ["human-subjects", "secure-export"]
};

function parseDate(value, field) {
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid date for ${field}: ${value}`);
}
return date;
}

function daysBetween(a, b) {
return Math.floor((b.getTime() - a.getTime()) / DAY_MS);
}

function buildFinding(code, severity, researcherId, message, remediation) {
return { code, severity, researcherId, message, remediation };
}

function requiredTrainingFor(researcher, action) {
const roleRequired = ROLE_TRAINING[researcher.role] || [];
return Array.from(new Set([...(action.requiredTraining || []), ...roleRequired])).sort();
}

function evaluateResearcher(researcher, action, now) {
const findings = [];
const policies = researcher.policies || {};
const training = researcher.training || {};

for (const policy of action.requiredPolicies || []) {
if (!policies[policy]) {
findings.push(buildFinding(
"POLICY_ACK_MISSING",
"block",
researcher.id,
`${researcher.id} has not acknowledged required policy ${policy}.`,
`Collect ${policy} acknowledgement before ${action.type}.`
));
continue;
}

const acknowledgedAt = parseDate(policies[policy], `${researcher.id}.${policy}`);
if (daysBetween(acknowledgedAt, now) > 180) {
findings.push(buildFinding(
"POLICY_ACK_STALE",
"revise",
researcher.id,
`${researcher.id} acknowledged ${policy} more than 180 days ago.`,
`Refresh ${policy} acknowledgement and attach the receipt.`
));
}
}

for (const moduleName of requiredTrainingFor(researcher, action)) {
const record = training[moduleName];
if (!record) {
findings.push(buildFinding(
"TRAINING_MISSING",
"block",
researcher.id,
`${researcher.id} is missing required training ${moduleName}.`,
`Complete ${moduleName} training before privileged access is released.`
));
continue;
}

const expiresAt = parseDate(record.expiresAt, `${researcher.id}.${moduleName}.expiresAt`);
if (expiresAt < now) {
findings.push(buildFinding(
"TRAINING_EXPIRED",
"block",
researcher.id,
`${researcher.id} has expired ${moduleName} training.`,
`Renew ${moduleName} training and upload a current certificate.`
));
}

if (!record.receiptId || String(record.receiptId).trim() === "") {
findings.push(buildFinding(
"TRAINING_RECEIPT_MISSING",
"revise",
researcher.id,
`${researcher.id} has ${moduleName} completion without evidence receipt.`,
`Attach a signed receipt for ${moduleName}.`
));
}
}

return findings;
}

function summarizeDecision(findings) {
if (findings.some((item) => item.severity === "block")) {
return "hold";
}
if (findings.some((item) => item.severity === "revise")) {
return "revise";
}
return "release";
}

function evaluateTrainingPolicyPacket(packet) {
if (!packet || typeof packet !== "object") {
throw new Error("Packet must be an object.");
}
if (!packet.action || !Array.isArray(packet.researchers)) {
throw new Error("Packet requires action and researchers.");
}

const now = new Date(packet.generatedAt || Date.now());
if (Number.isNaN(now.getTime())) {
throw new Error("generatedAt must be a valid timestamp.");
}

const findings = packet.researchers.flatMap((researcher) =>
evaluateResearcher(researcher, packet.action, now)
);
const decision = summarizeDecision(findings);

return {
actionId: packet.action.id,
projectId: packet.action.projectId,
department: packet.action.department,
decision,
checkedResearchers: packet.researchers.length,
blockerCount: findings.filter((item) => item.severity === "block").length,
revisionCount: findings.filter((item) => item.severity === "revise").length,
findings,
reviewerSummary: decision === "release"
? "All required enterprise training and policy acknowledgements are current."
: "Hold or revise before enterprise export/privileged access continues."
};
}

module.exports = {
ROLE_TRAINING,
evaluateTrainingPolicyPacket,
requiredTrainingFor,
summarizeDecision
};
13 changes: 13 additions & 0 deletions enterprise-training-policy-guard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "enterprise-training-policy-guard",
"version": "1.0.0",
"description": "Enterprise training and policy acknowledgement expiry guard for SCIBASE Enterprise Tooling",
"main": "index.js",
"type": "commonjs",
"scripts": {
"test": "node test.js",
"demo": "node demo.js",
"demo:video": "python demo_video.py"
},
"license": "MIT"
}
1 change: 1 addition & 0 deletions enterprise-training-policy-guard/reports/demo-script.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Show the release scenario, then the restricted export hold scenario, then the remediation list.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added enterprise-training-policy-guard/reports/demo.mp4
Binary file not shown.
15 changes: 15 additions & 0 deletions enterprise-training-policy-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading