shield-harness 0.1.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.
Files changed (43) hide show
  1. package/.claude/hooks/lib/ocsf-mapper.js +279 -0
  2. package/.claude/hooks/lib/openshell-detect.js +235 -0
  3. package/.claude/hooks/lib/policy-compat.js +176 -0
  4. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  5. package/.claude/hooks/lib/sh-utils.js +340 -0
  6. package/.claude/hooks/lint-on-save.js +240 -0
  7. package/.claude/hooks/sh-circuit-breaker.js +113 -0
  8. package/.claude/hooks/sh-config-guard.js +275 -0
  9. package/.claude/hooks/sh-data-boundary.js +390 -0
  10. package/.claude/hooks/sh-dep-audit.js +101 -0
  11. package/.claude/hooks/sh-elicitation.js +244 -0
  12. package/.claude/hooks/sh-evidence.js +193 -0
  13. package/.claude/hooks/sh-gate.js +365 -0
  14. package/.claude/hooks/sh-injection-guard.js +196 -0
  15. package/.claude/hooks/sh-instructions.js +212 -0
  16. package/.claude/hooks/sh-output-control.js +217 -0
  17. package/.claude/hooks/sh-permission-learn.js +227 -0
  18. package/.claude/hooks/sh-permission.js +157 -0
  19. package/.claude/hooks/sh-pipeline.js +623 -0
  20. package/.claude/hooks/sh-postcompact.js +173 -0
  21. package/.claude/hooks/sh-precompact.js +114 -0
  22. package/.claude/hooks/sh-quiet-inject.js +148 -0
  23. package/.claude/hooks/sh-session-end.js +143 -0
  24. package/.claude/hooks/sh-session-start.js +277 -0
  25. package/.claude/hooks/sh-subagent.js +86 -0
  26. package/.claude/hooks/sh-task-gate.js +141 -0
  27. package/.claude/hooks/sh-user-prompt.js +185 -0
  28. package/.claude/hooks/sh-worktree.js +230 -0
  29. package/.claude/patterns/injection-patterns.json +137 -0
  30. package/.claude/policies/openshell-default.yaml +65 -0
  31. package/.claude/rules/binding-governance.md +62 -0
  32. package/.claude/rules/channel-security.md +90 -0
  33. package/.claude/rules/coding-principles.md +79 -0
  34. package/.claude/rules/dev-environment.md +40 -0
  35. package/.claude/rules/implementation-context.md +132 -0
  36. package/.claude/rules/language.md +26 -0
  37. package/.claude/rules/security.md +109 -0
  38. package/.claude/rules/testing.md +43 -0
  39. package/LICENSE +21 -0
  40. package/README.ja.md +176 -0
  41. package/README.md +174 -0
  42. package/bin/shield-harness.js +241 -0
  43. package/package.json +42 -0
@@ -0,0 +1,279 @@
1
+ // ocsf-mapper.js — OCSF Detection Finding (class_uid: 2004) transformer
2
+ // Maps Shield Harness hook evidence entries to OCSF Detection Finding format.
3
+ // Spec: https://schema.ocsf.io/ (v1.3.0)
4
+ "use strict";
5
+
6
+ const crypto = require("crypto");
7
+ const path = require("path");
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Constants
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const OCSF_VERSION = "1.3.0";
14
+ const CLASS_UID = 2004;
15
+ const CATEGORY_UID = 2;
16
+ const TYPE_UID = 200401; // Detection Finding: Create
17
+
18
+ // Product version from package.json (cached at module load)
19
+ let productVersion = "1.0.0";
20
+ try {
21
+ productVersion = require(path.resolve("package.json")).version;
22
+ } catch {
23
+ // Fallback — running outside project root
24
+ }
25
+
26
+ // Fields that map to OCSF common fields (not placed in unmapped)
27
+ const OCSF_COMMON_FIELDS = new Set([
28
+ "hook",
29
+ "event",
30
+ "decision",
31
+ "session_id",
32
+ "tool",
33
+ "seq",
34
+ "severity",
35
+ ]);
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Severity mapping
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const SEVERITY_NAMES = {
42
+ 1: "Informational",
43
+ 2: "Low",
44
+ 3: "Medium",
45
+ 4: "High",
46
+ 5: "Critical",
47
+ };
48
+
49
+ /**
50
+ * Resolve OCSF severity_id from hook context.
51
+ * @param {string} hook
52
+ * @param {string} decision
53
+ * @param {string} [severity] - Hook-provided severity string
54
+ * @returns {number} 1-5
55
+ */
56
+ function resolveSeverityId(hook, decision, severity) {
57
+ if (decision !== "deny" && !severity) return 1; // Informational for allow
58
+
59
+ // If hook provides explicit severity, map it
60
+ if (severity) {
61
+ const map = { critical: 5, high: 4, medium: 3, low: 2 };
62
+ return map[severity.toLowerCase()] || 3;
63
+ }
64
+
65
+ // Hook-specific deny severity
66
+ const hookSeverity = {
67
+ "sh-injection-guard": 4,
68
+ "sh-config-guard": 4,
69
+ "sh-data-boundary": 4,
70
+ "sh-gate": 3,
71
+ "sh-user-prompt": 3,
72
+ "sh-circuit-breaker": 3,
73
+ "sh-elicitation": 3,
74
+ "sh-permission": 3,
75
+ };
76
+ return hookSeverity[hook] || 3;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Disposition mapping
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Resolve OCSF disposition_id.
85
+ * @param {string} decision
86
+ * @param {string|null} category
87
+ * @returns {number}
88
+ */
89
+ function resolveDispositionId(decision, category) {
90
+ if (decision === "deny") return 2; // Blocked
91
+ if (category === "pii_detected" || category === "leakage_detected") {
92
+ return 15; // Detected
93
+ }
94
+ return 1; // Allowed
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Title generation
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const TITLE_TEMPLATES = {
102
+ "sh-evidence": (e) => `Tool execution recorded: ${e.tool || "unknown"}`,
103
+ "sh-config-guard": (e) =>
104
+ `Configuration change ${e.action || "check"}: ${e.decision}`,
105
+ "sh-injection-guard": (e) =>
106
+ `Injection pattern detected: ${e.category || "unknown"}`,
107
+ "sh-circuit-breaker": (e) => `Circuit breaker: ${e.reason || e.decision}`,
108
+ "sh-gate": (e) => `Bash command gate: ${e.decision}`,
109
+ "sh-data-boundary": (e) =>
110
+ `Data boundary violation: ${e.host || "unknown host"}`,
111
+ "sh-instructions": (e) => `Instructions integrity: ${e.action || "check"}`,
112
+ "sh-elicitation": (e) => `Elicitation check: ${e.reason || e.decision}`,
113
+ "sh-dep-audit": (e) => `Dependency audit: ${e.reason || "recorded"}`,
114
+ "sh-precompact": () => "Pre-compaction backup",
115
+ "sh-postcompact": () => "Post-compaction integrity check",
116
+ "sh-session-end": () => "Session closed",
117
+ "sh-session-start": () => "Session initialized",
118
+ "sh-subagent": () => "Subagent budget allocated",
119
+ "sh-worktree": (e) => `Worktree operation: ${e.event || "unknown"}`,
120
+ "sh-permission-learn": (e) =>
121
+ `Permission learning: ${e.reason || "recorded"}`,
122
+ "sh-permission": (e) => `Permission check: ${e.decision}`,
123
+ "sh-pipeline": (e) => `Pipeline stage: ${e.stage || "unknown"}`,
124
+ "sh-task-gate": (e) => `Task gate: ${e.reason || "check"}`,
125
+ "sh-user-prompt": (e) =>
126
+ `User prompt scan: ${e.category || "clean"} (${e.severity || "info"})`,
127
+ "sh-output-control": (e) => `Output control: ${e.decision || "allow"}`,
128
+ };
129
+
130
+ /**
131
+ * Generate finding_info.title from hook and entry.
132
+ * @param {string} hook
133
+ * @param {Object} entry
134
+ * @returns {string}
135
+ */
136
+ function generateTitle(hook, entry) {
137
+ const template = TITLE_TEMPLATES[hook];
138
+ if (template) return template(entry);
139
+ return `${hook}: ${entry.decision || entry.event || "recorded"}`;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // UUID generation (Node.js 18 compatible)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ function generateUUID() {
147
+ if (typeof crypto.randomUUID === "function") {
148
+ return crypto.randomUUID();
149
+ }
150
+ // Fallback for Node.js < 19
151
+ const bytes = crypto.randomBytes(16);
152
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
153
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
154
+ const hex = bytes.toString("hex");
155
+ return [
156
+ hex.slice(0, 8),
157
+ hex.slice(8, 12),
158
+ hex.slice(12, 16),
159
+ hex.slice(16, 20),
160
+ hex.slice(20, 32),
161
+ ].join("-");
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Unmapped field extraction
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Extract hook-specific fields that have no OCSF mapping.
170
+ * @param {Object} entry
171
+ * @returns {Object}
172
+ */
173
+ function extractUnmapped(entry) {
174
+ const unmapped = {};
175
+ for (const [key, value] of Object.entries(entry)) {
176
+ if (!OCSF_COMMON_FIELDS.has(key) && value !== undefined) {
177
+ unmapped[key] = value;
178
+ }
179
+ }
180
+ return Object.keys(unmapped).length > 0 ? unmapped : undefined;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Main transformer
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Transform a hook evidence entry to OCSF Detection Finding (class_uid: 2004).
189
+ * @param {Object} entry - Raw hook evidence entry
190
+ * @returns {Object} OCSF-compliant Detection Finding
191
+ */
192
+ function toDetectionFinding(entry) {
193
+ const hook = entry.hook || "unknown";
194
+ const decision = entry.decision || "allow";
195
+ const severityId = resolveSeverityId(hook, decision, entry.severity);
196
+ const dispositionId = resolveDispositionId(decision, entry.category);
197
+ const isAllow = decision === "allow";
198
+
199
+ const finding = {
200
+ // OCSF required
201
+ class_uid: CLASS_UID,
202
+ class_name: "Detection Finding",
203
+ category_uid: CATEGORY_UID,
204
+ category_name: "Findings",
205
+ type_uid: TYPE_UID,
206
+ activity_id: 1,
207
+ time: Date.now(),
208
+ severity_id: severityId,
209
+ severity: SEVERITY_NAMES[severityId] || "Unknown",
210
+ status_id: 1,
211
+ status: "New",
212
+
213
+ // OCSF recommended
214
+ action_id: isAllow ? 1 : 2,
215
+ action: isAllow ? "Allowed" : "Denied",
216
+ disposition_id: dispositionId,
217
+
218
+ // Metadata
219
+ metadata: {
220
+ version: OCSF_VERSION,
221
+ product: {
222
+ name: "Shield Harness",
223
+ vendor_name: "Shield Harness",
224
+ version: productVersion,
225
+ },
226
+ log_name: "evidence-ledger",
227
+ },
228
+
229
+ // Finding info
230
+ finding_info: {
231
+ uid: generateUUID(),
232
+ title: generateTitle(hook, entry),
233
+ analytic: {
234
+ type_id: 1,
235
+ type: "Rule",
236
+ name: hook,
237
+ uid: hook,
238
+ },
239
+ },
240
+ };
241
+
242
+ // Correlation (session_id → metadata.correlation_uid)
243
+ if (entry.session_id) {
244
+ finding.metadata.correlation_uid = entry.session_id;
245
+ }
246
+
247
+ // Sequence (seq → metadata.sequence)
248
+ if (typeof entry.seq === "number") {
249
+ finding.metadata.sequence = entry.seq;
250
+ }
251
+
252
+ // Resources (tool name)
253
+ if (entry.tool) {
254
+ finding.resources = [{ type: "tool", name: entry.tool }];
255
+ }
256
+
257
+ // Unmapped (hook-specific fields)
258
+ const unmapped = extractUnmapped(entry);
259
+ if (unmapped) {
260
+ finding.unmapped = unmapped;
261
+ }
262
+
263
+ return finding;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Exports
268
+ // ---------------------------------------------------------------------------
269
+
270
+ module.exports = {
271
+ toDetectionFinding,
272
+ // Exported for testing
273
+ resolveSeverityId,
274
+ resolveDispositionId,
275
+ generateTitle,
276
+ extractUnmapped,
277
+ generateUUID,
278
+ OCSF_VERSION,
279
+ };
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ // openshell-detect.js — NVIDIA OpenShell detection & version tracking
3
+ // Spec: DETAILED_DESIGN.md §5.1.2, ADR-037
4
+ // Purpose: Detect OpenShell availability at SessionStart, track version updates
5
+ "use strict";
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const { execSync } = require("child_process");
10
+ const { commandExists, SH_DIR } = require("./sh-utils");
11
+
12
+ const CACHE_DIR = path.join(SH_DIR, "state");
13
+ const CACHE_FILE = path.join(CACHE_DIR, "openshell-version-cache.json");
14
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
15
+ const CMD_TIMEOUT = 3000; // 3 seconds
16
+ const FETCH_TIMEOUT = 5000; // 5 seconds
17
+ const RELEASES_URL =
18
+ "https://api.github.com/repos/NVIDIA/OpenShell/releases/latest";
19
+
20
+ /**
21
+ * Run a command synchronously with timeout. Returns stdout or null on failure.
22
+ * @param {string} cmd
23
+ * @param {number} [timeout]
24
+ * @returns {string|null}
25
+ */
26
+ function runCmd(cmd, timeout = CMD_TIMEOUT) {
27
+ try {
28
+ return execSync(cmd, {
29
+ encoding: "utf8",
30
+ timeout,
31
+ stdio: ["pipe", "pipe", "pipe"],
32
+ }).trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Parse version string from openshell --version output.
40
+ * Expected format: "openshell X.Y.Z" or just "X.Y.Z"
41
+ * @param {string} output
42
+ * @returns {string|null}
43
+ */
44
+ function parseVersion(output) {
45
+ if (!output) return null;
46
+ const match = output.match(/(\d+\.\d+\.\d+)/);
47
+ return match ? match[1] : null;
48
+ }
49
+
50
+ /**
51
+ * Read version cache file.
52
+ * @returns {{ latest_version: string, checked_at: string, current_version: string }|null}
53
+ */
54
+ function readCache() {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Write version cache file atomically.
64
+ * @param {Object} data
65
+ */
66
+ function writeCache(data) {
67
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
68
+ const tmp = `${CACHE_FILE}.tmp`;
69
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
70
+ fs.renameSync(tmp, CACHE_FILE);
71
+ }
72
+
73
+ /**
74
+ * Fetch latest OpenShell version from GitHub Releases API.
75
+ * Uses curl if available, otherwise Node.js https as fallback.
76
+ * Returns null on any failure (fail-safe).
77
+ * @returns {string|null} version string (e.g., "0.0.14") or null
78
+ */
79
+ function fetchLatestVersion() {
80
+ // Try curl first (simpler, faster)
81
+ if (commandExists("curl")) {
82
+ const raw = runCmd(
83
+ `curl -s -H "Accept: application/vnd.github.v3+json" -H "User-Agent: shield-harness" "${RELEASES_URL}"`,
84
+ FETCH_TIMEOUT,
85
+ );
86
+ if (raw) {
87
+ try {
88
+ const data = JSON.parse(raw);
89
+ return (data.tag_name || "").replace(/^v/, "") || null;
90
+ } catch {
91
+ // Parse error — fall through
92
+ }
93
+ }
94
+ }
95
+
96
+ // Fallback: Node.js https (via temp script to avoid shell quoting issues)
97
+ const tmpScript = path.join(CACHE_DIR, "fetch-version.js");
98
+ try {
99
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
100
+ fs.writeFileSync(
101
+ tmpScript,
102
+ 'const https=require("https");' +
103
+ 'const o={hostname:"api.github.com",path:"/repos/NVIDIA/OpenShell/releases/latest",' +
104
+ 'headers:{"User-Agent":"shield-harness","Accept":"application/vnd.github.v3+json"}};' +
105
+ 'https.get(o,(r)=>{let d="";r.on("data",(c)=>d+=c);' +
106
+ 'r.on("end",()=>{try{process.stdout.write(JSON.parse(d).tag_name||"")}catch{}});})' +
107
+ '.on("error",()=>{});',
108
+ );
109
+ const tag = runCmd(`node "${tmpScript}"`, FETCH_TIMEOUT);
110
+ try {
111
+ fs.unlinkSync(tmpScript);
112
+ } catch {
113
+ // cleanup failure is non-critical
114
+ }
115
+ return tag ? tag.replace(/^v/, "") || null : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Check latest version with 24-hour cache.
123
+ * @param {string} currentVersion
124
+ * @returns {{ latest_version: string|null, update_available: boolean }}
125
+ */
126
+ function checkLatestVersion(currentVersion) {
127
+ const cache = readCache();
128
+ const now = Date.now();
129
+
130
+ // Use cache if within TTL
131
+ if (cache && cache.checked_at) {
132
+ const elapsed = now - new Date(cache.checked_at).getTime();
133
+ if (elapsed < CACHE_TTL_MS && cache.latest_version) {
134
+ return {
135
+ latest_version: cache.latest_version,
136
+ update_available: cache.latest_version !== currentVersion,
137
+ };
138
+ }
139
+ }
140
+
141
+ // Fetch from GitHub
142
+ const latest = fetchLatestVersion();
143
+ if (latest) {
144
+ writeCache({
145
+ latest_version: latest,
146
+ checked_at: new Date(now).toISOString(),
147
+ current_version: currentVersion,
148
+ });
149
+ return {
150
+ latest_version: latest,
151
+ update_available: latest !== currentVersion,
152
+ };
153
+ }
154
+
155
+ // Network failure — use stale cache if available
156
+ if (cache && cache.latest_version) {
157
+ return {
158
+ latest_version: cache.latest_version,
159
+ update_available: cache.latest_version !== currentVersion,
160
+ };
161
+ }
162
+
163
+ return { latest_version: null, update_available: false };
164
+ }
165
+
166
+ /**
167
+ * Detect OpenShell availability, version, container status, and update info.
168
+ * fail-safe: never throws, returns { available: false } on any error.
169
+ * @returns {{
170
+ * available: boolean,
171
+ * version: string|null,
172
+ * docker_available: boolean,
173
+ * container_running: boolean,
174
+ * reason: string|null,
175
+ * latest_version: string|null,
176
+ * update_available: boolean,
177
+ * detected_at: string
178
+ * }}
179
+ */
180
+ function detectOpenShell() {
181
+ const detected_at = new Date().toISOString();
182
+ const base = {
183
+ available: false,
184
+ version: null,
185
+ docker_available: false,
186
+ container_running: false,
187
+ reason: null,
188
+ latest_version: null,
189
+ update_available: false,
190
+ detected_at,
191
+ };
192
+
193
+ try {
194
+ // Step 1: Docker CLI
195
+ if (!commandExists("docker")) {
196
+ return { ...base, reason: "docker_not_found" };
197
+ }
198
+ base.docker_available = true;
199
+
200
+ // Step 2: OpenShell CLI
201
+ if (!commandExists("openshell")) {
202
+ return { ...base, reason: "openshell_not_installed" };
203
+ }
204
+
205
+ // Step 3: Version
206
+ const versionOutput = runCmd("openshell --version");
207
+ const version = parseVersion(versionOutput);
208
+ base.version = version;
209
+
210
+ // Step 4: Container status (strict match to avoid "inactive"/"No active" false positives)
211
+ const listOutput = runCmd("openshell sandbox list");
212
+ const running =
213
+ listOutput !== null &&
214
+ /\brunning\b/i.test(listOutput) &&
215
+ !/\b(not|no|in)active\b/i.test(listOutput);
216
+ base.container_running = running;
217
+
218
+ if (!running) {
219
+ return { ...base, reason: "container_not_running" };
220
+ }
221
+
222
+ // Step 5: Version tracking (24h cache)
223
+ const versionInfo = checkLatestVersion(version || "0.0.0");
224
+ base.latest_version = versionInfo.latest_version;
225
+ base.update_available = versionInfo.update_available;
226
+
227
+ base.available = true;
228
+ return base;
229
+ } catch {
230
+ // Catch-all: detection failure is not a security issue
231
+ return { ...base, reason: "detection_error" };
232
+ }
233
+ }
234
+
235
+ module.exports = { detectOpenShell, fetchLatestVersion, parseVersion };
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // policy-compat.js — Policy version compatibility check
3
+ // Spec: TASK-021, ADR-037 Phase Beta
4
+ // Purpose: Verify OpenShell policy schema version compatibility at SessionStart
5
+ "use strict";
6
+
7
+ const fs = require("fs");
8
+
9
+ /**
10
+ * Compatibility matrix: OpenShell CLI version range → supported policy schema versions.
11
+ * Each entry defines which policy schema versions are supported by a given
12
+ * OpenShell CLI version range (min inclusive, max exclusive).
13
+ *
14
+ * Update this matrix when OpenShell releases breaking policy schema changes.
15
+ * @type {Array<{openshell_min: string, openshell_max: string, supported_policy_versions: number[], latest_policy_version: number}>}
16
+ */
17
+ const COMPAT_MATRIX = [
18
+ {
19
+ // OpenShell Alpha (0.0.x): policy schema v1 only
20
+ openshell_min: "0.0.0",
21
+ openshell_max: "1.0.0",
22
+ supported_policy_versions: [1],
23
+ latest_policy_version: 1,
24
+ },
25
+ // Future entries (example):
26
+ // {
27
+ // openshell_min: "1.0.0",
28
+ // openshell_max: "2.0.0",
29
+ // supported_policy_versions: [1, 2],
30
+ // latest_policy_version: 2,
31
+ // },
32
+ ];
33
+
34
+ /**
35
+ * Extract policy schema version from YAML content string.
36
+ * Uses regex to avoid js-yaml dependency (zero external deps).
37
+ * Only matches top-level (non-indented, non-commented) version: field.
38
+ * @param {string} content - Raw YAML file content
39
+ * @returns {number|null} - Extracted version number, or null if not found/invalid
40
+ */
41
+ function extractPolicyVersion(content) {
42
+ if (!content) return null;
43
+ // Match top-level version field: no leading whitespace, not in a comment
44
+ // Supports: version: 1, version: "1", version: '1'
45
+ const match = content.match(/^version:\s*["']?(\d+)["']?/m);
46
+ if (!match) return null;
47
+ // Verify the matched line is not indented (top-level only)
48
+ const lineStart = content.lastIndexOf("\n", match.index) + 1;
49
+ if (match.index > lineStart) return null; // indented
50
+ // Verify line is not a comment
51
+ const linePrefix = content.slice(lineStart, match.index);
52
+ if (linePrefix.includes("#")) return null;
53
+ return parseInt(match[1], 10);
54
+ }
55
+
56
+ /**
57
+ * Compare two semver strings (X.Y.Z format).
58
+ * @param {string} a - First version string
59
+ * @param {string} b - Second version string
60
+ * @returns {-1|0|1} - -1 if a < b, 0 if equal, 1 if a > b
61
+ */
62
+ function compareSemver(a, b) {
63
+ const pa = a.split(".").map(Number);
64
+ const pb = b.split(".").map(Number);
65
+ for (let i = 0; i < 3; i++) {
66
+ const va = pa[i] || 0;
67
+ const vb = pb[i] || 0;
68
+ if (va < vb) return -1;
69
+ if (va > vb) return 1;
70
+ }
71
+ return 0;
72
+ }
73
+
74
+ /**
75
+ * Check if a semver version falls within a range [min, max).
76
+ * @param {string} version - Version to check
77
+ * @param {string} min - Minimum version (inclusive)
78
+ * @param {string} maxExclusive - Maximum version (exclusive)
79
+ * @returns {boolean}
80
+ */
81
+ function semverInRange(version, min, maxExclusive) {
82
+ return (
83
+ compareSemver(version, min) >= 0 && compareSemver(version, maxExclusive) < 0
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Check policy file schema version compatibility with installed OpenShell version.
89
+ * fail-safe: never throws, returns { compatible: null } on any error.
90
+ * @param {{ openshellVersion: string|null, policyFilePath: string }} params
91
+ * @returns {{
92
+ * compatible: boolean|null,
93
+ * policy_version: number|null,
94
+ * openshell_version: string|null,
95
+ * reason: string|null,
96
+ * recommended_policy_version: number|null,
97
+ * migration_hint: string|null,
98
+ * checked_at: string
99
+ * }}
100
+ */
101
+ function checkPolicyCompatibility({ openshellVersion, policyFilePath }) {
102
+ const checked_at = new Date().toISOString();
103
+ const base = {
104
+ compatible: null,
105
+ policy_version: null,
106
+ openshell_version: openshellVersion || null,
107
+ reason: null,
108
+ recommended_policy_version: null,
109
+ migration_hint: null,
110
+ checked_at,
111
+ };
112
+
113
+ try {
114
+ // Step 1: Read policy file
115
+ if (!fs.existsSync(policyFilePath)) {
116
+ return { ...base, reason: "policy_not_found" };
117
+ }
118
+
119
+ const content = fs.readFileSync(policyFilePath, "utf8");
120
+
121
+ // Step 2: Extract schema version
122
+ const policyVersion = extractPolicyVersion(content);
123
+ base.policy_version = policyVersion;
124
+
125
+ if (policyVersion == null) {
126
+ return { ...base, reason: "version_not_readable" };
127
+ }
128
+
129
+ // Step 3: Check OpenShell version availability
130
+ if (!openshellVersion) {
131
+ return { ...base, reason: "openshell_version_unknown" };
132
+ }
133
+
134
+ // Step 4: Find matching matrix entry
135
+ const entry = COMPAT_MATRIX.find((e) =>
136
+ semverInRange(openshellVersion, e.openshell_min, e.openshell_max),
137
+ );
138
+
139
+ if (!entry) {
140
+ return { ...base, reason: "unknown_combination" };
141
+ }
142
+
143
+ // Step 5: Check compatibility
144
+ if (entry.supported_policy_versions.includes(policyVersion)) {
145
+ return { ...base, compatible: true };
146
+ }
147
+
148
+ // Incompatible: policy version not supported by this OpenShell range
149
+ return {
150
+ ...base,
151
+ compatible: false,
152
+ recommended_policy_version: entry.latest_policy_version,
153
+ migration_hint:
154
+ "Policy schema v" +
155
+ policyVersion +
156
+ " is not supported by OpenShell v" +
157
+ openshellVersion +
158
+ ". " +
159
+ "Supported versions: " +
160
+ entry.supported_policy_versions.join(", ") +
161
+ ". " +
162
+ "Regenerate policy with: npx shield-harness init --policy",
163
+ };
164
+ } catch {
165
+ // fail-safe: any unexpected error returns unknown
166
+ return { ...base, reason: "check_error" };
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ extractPolicyVersion,
172
+ compareSemver,
173
+ semverInRange,
174
+ checkPolicyCompatibility,
175
+ COMPAT_MATRIX,
176
+ };
File without changes