shield-harness 1.0.0 → 1.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.
- package/.claude/hooks/lib/ocsf-mapper.js +279 -0
- package/.claude/hooks/lib/openshell-detect.js +235 -0
- package/.claude/hooks/lib/policy-compat.js +176 -0
- package/.claude/hooks/lib/sh-utils.js +100 -1
- package/.claude/hooks/sh-circuit-breaker.js +2 -0
- package/.claude/hooks/sh-config-guard.js +92 -69
- package/.claude/hooks/sh-data-boundary.js +148 -73
- package/.claude/hooks/sh-elicitation.js +3 -0
- package/.claude/hooks/sh-gate.js +189 -154
- package/.claude/hooks/sh-injection-guard.js +141 -110
- package/.claude/hooks/sh-instructions.js +2 -0
- package/.claude/hooks/sh-output-control.js +68 -34
- package/.claude/hooks/sh-permission-learn.js +4 -0
- package/.claude/hooks/sh-pipeline.js +6 -22
- package/.claude/hooks/sh-quiet-inject.js +1 -0
- package/.claude/hooks/sh-session-start.js +82 -1
- package/.claude/hooks/sh-task-gate.js +3 -0
- package/.claude/hooks/sh-user-prompt.js +4 -0
- package/.claude/hooks/sh-worktree.js +3 -0
- package/.claude/patterns/injection-patterns.json +1 -1
- package/.claude/policies/openshell-default.yaml +65 -0
- package/.claude/rules/dev-environment.md +8 -5
- package/.claude/rules/implementation-context.md +58 -38
- package/README.ja.md +82 -15
- package/README.md +79 -12
- package/bin/shield-harness.js +100 -0
- package/package.json +11 -2
|
@@ -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
|
+
};
|