@danielblomma/cortex-mcp 2.4.0 → 2.4.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/CHANGELOG.md +201 -0
- package/README.md +19 -3
- package/bin/cortex.mjs +308 -75
- package/bin/daemon-control.mjs +162 -0
- package/package.json +7 -3
- package/scaffold/mcp/dist/.cortex-build-hash +1 -0
- package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
- package/scaffold/mcp/dist/cli/govern.js +937 -0
- package/scaffold/mcp/dist/cli/query.js +409 -0
- package/scaffold/mcp/dist/cli/run.js +295 -0
- package/scaffold/mcp/dist/cli/stage.js +308 -0
- package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
- package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
- package/scaffold/mcp/dist/contextEntities.js +282 -0
- package/scaffold/mcp/dist/core/audit/query.js +72 -0
- package/scaffold/mcp/dist/core/audit/writer.js +28 -0
- package/scaffold/mcp/dist/core/config.js +235 -0
- package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
- package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
- package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
- package/scaffold/mcp/dist/core/govern-paths.js +21 -0
- package/scaffold/mcp/dist/core/index.js +14 -0
- package/scaffold/mcp/dist/core/license.js +278 -0
- package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
- package/scaffold/mcp/dist/core/policy/injection.js +172 -0
- package/scaffold/mcp/dist/core/policy/store.js +179 -0
- package/scaffold/mcp/dist/core/rbac/check.js +30 -0
- package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
- package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
- package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
- package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
- package/scaffold/mcp/dist/core/validators/config.js +44 -0
- package/scaffold/mcp/dist/core/validators/engine.js +120 -0
- package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
- package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
- package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
- package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
- package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
- package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
- package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
- package/scaffold/mcp/dist/core/workflow/index.js +11 -0
- package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
- package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
- package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
- package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
- package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
- package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
- package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
- package/scaffold/mcp/dist/daemon/client.js +130 -0
- package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
- package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
- package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
- package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
- package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
- package/scaffold/mcp/dist/daemon/main.js +449 -0
- package/scaffold/mcp/dist/daemon/paths.js +36 -0
- package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
- package/scaffold/mcp/dist/daemon/protocol.js +8 -0
- package/scaffold/mcp/dist/daemon/server.js +180 -0
- package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
- package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
- package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
- package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
- package/scaffold/mcp/dist/defaults.js +6 -0
- package/scaffold/mcp/dist/embed.js +627 -0
- package/scaffold/mcp/dist/embedScheduler.js +479 -0
- package/scaffold/mcp/dist/embeddings.js +167 -0
- package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
- package/scaffold/mcp/dist/enterprise/index.js +327 -0
- package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
- package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
- package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
- package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
- package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
- package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
- package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
- package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
- package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
- package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
- package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
- package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
- package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
- package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
- package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
- package/scaffold/mcp/dist/frontmatter.js +33 -0
- package/scaffold/mcp/dist/graph.js +769 -0
- package/scaffold/mcp/dist/graphCsv.js +55 -0
- package/scaffold/mcp/dist/graphMetrics.js +8 -0
- package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
- package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
- package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
- package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
- package/scaffold/mcp/dist/hooks/session-end.js +43 -0
- package/scaffold/mcp/dist/hooks/session-start.js +41 -0
- package/scaffold/mcp/dist/hooks/shared.js +194 -0
- package/scaffold/mcp/dist/hooks/stop.js +28 -0
- package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
- package/scaffold/mcp/dist/impactPresentation.js +137 -0
- package/scaffold/mcp/dist/impactRanking.js +191 -0
- package/scaffold/mcp/dist/impactResponse.js +30 -0
- package/scaffold/mcp/dist/impactResults.js +105 -0
- package/scaffold/mcp/dist/impactSeed.js +20 -0
- package/scaffold/mcp/dist/impactTraversal.js +64 -0
- package/scaffold/mcp/dist/jsonl.js +77 -0
- package/scaffold/mcp/dist/loadGraph.js +759 -0
- package/scaffold/mcp/dist/lruCache.js +38 -0
- package/scaffold/mcp/dist/paths.js +97 -0
- package/scaffold/mcp/dist/patternEvidence.js +272 -0
- package/scaffold/mcp/dist/plugin.js +81 -0
- package/scaffold/mcp/dist/presets.js +78 -0
- package/scaffold/mcp/dist/relatedResponse.js +18 -0
- package/scaffold/mcp/dist/relatedTraversal.js +78 -0
- package/scaffold/mcp/dist/rules.js +23 -0
- package/scaffold/mcp/dist/search.js +212 -0
- package/scaffold/mcp/dist/searchCore.js +457 -0
- package/scaffold/mcp/dist/searchResults.js +230 -0
- package/scaffold/mcp/dist/server.js +317 -0
- package/scaffold/mcp/dist/types.js +1 -0
- package/scaffold/mcp/package-lock.json +321 -200
- package/scaffold/mcp/package.json +15 -6
- package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
- package/scaffold/mcp/src/cli/govern.ts +137 -52
- package/scaffold/mcp/src/cli/run.ts +53 -19
- package/scaffold/mcp/src/cli/stage.ts +8 -3
- package/scaffold/mcp/src/core/config.ts +9 -2
- package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
- package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
- package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
- package/scaffold/mcp/src/core/govern-paths.ts +30 -0
- package/scaffold/mcp/src/core/license.ts +186 -17
- package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
- package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
- package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
- package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
- package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
- package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
- package/scaffold/mcp/src/daemon/main.ts +99 -25
- package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
- package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
- package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
- package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
- package/scaffold/mcp/src/plugin.ts +20 -0
- package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
- package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
- package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
- package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
- package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
- package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
- package/scaffold/mcp/tests/license.test.mjs +367 -0
- package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
- package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
- package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
- package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
- package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
- package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { chownSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const HOST_IDENTITY_FILENAME = "enterprise-host-identity.json";
|
|
6
|
+
const HOST_IDENTITY_VERSION = 2;
|
|
7
|
+
function configuredHomeDir(homeDir) {
|
|
8
|
+
return homeDir ?? process.env.HOME?.trim() ?? homedir();
|
|
9
|
+
}
|
|
10
|
+
function hostIdentityDir(options = {}, create = false) {
|
|
11
|
+
try {
|
|
12
|
+
const home = realpathSync(configuredHomeDir(options.homeDir));
|
|
13
|
+
const dir = join(home, ".cortex");
|
|
14
|
+
if (existsSync(dir)) {
|
|
15
|
+
const stat = lstatSync(dir);
|
|
16
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
17
|
+
return null;
|
|
18
|
+
if (realpathSync(dir) !== dir)
|
|
19
|
+
return null;
|
|
20
|
+
if (options.owner) {
|
|
21
|
+
if (stat.uid !== options.owner.uid && stat.uid !== 0)
|
|
22
|
+
return null;
|
|
23
|
+
chownSync(dir, options.owner.uid, options.owner.gid);
|
|
24
|
+
chmodSync(dir, 0o700);
|
|
25
|
+
}
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
if (!create)
|
|
29
|
+
return null;
|
|
30
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
31
|
+
if (options.owner) {
|
|
32
|
+
chownSync(dir, options.owner.uid, options.owner.gid);
|
|
33
|
+
}
|
|
34
|
+
chmodSync(dir, 0o700);
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function hostIdentityPath(options = {}, createDir = false) {
|
|
42
|
+
const dir = hostIdentityDir(options, createDir);
|
|
43
|
+
return dir ? join(dir, HOST_IDENTITY_FILENAME) : null;
|
|
44
|
+
}
|
|
45
|
+
function readHostIdentity(options = {}) {
|
|
46
|
+
const path = hostIdentityPath(options);
|
|
47
|
+
if (!path)
|
|
48
|
+
return null;
|
|
49
|
+
if (!existsSync(path))
|
|
50
|
+
return null;
|
|
51
|
+
try {
|
|
52
|
+
const stat = lstatSync(path);
|
|
53
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
54
|
+
return null;
|
|
55
|
+
if (options.owner &&
|
|
56
|
+
stat.uid !== options.owner.uid &&
|
|
57
|
+
stat.uid !== 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
61
|
+
if (parsed.version !== HOST_IDENTITY_VERSION ||
|
|
62
|
+
typeof parsed.credential_id !== "string" ||
|
|
63
|
+
!/^[a-f0-9]{64}$/.test(parsed.credential_id) ||
|
|
64
|
+
typeof parsed.endpoint_sha256 !== "string" ||
|
|
65
|
+
!/^[a-f0-9]{64}$/.test(parsed.endpoint_sha256)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Atomically bind user-global Enterprise services and artifacts to one
|
|
76
|
+
* endpoint/API-key identity. Malformed existing enrollment fails closed.
|
|
77
|
+
*/
|
|
78
|
+
export function claimEnterpriseHostIdentity(credentialId, endpoint, options = {}) {
|
|
79
|
+
if (!/^[a-f0-9]{64}$/.test(credentialId))
|
|
80
|
+
return false;
|
|
81
|
+
const endpointSha256 = createHash("sha256")
|
|
82
|
+
.update(endpoint.trim().replace(/\/+$/, ""), "utf8")
|
|
83
|
+
.digest("hex");
|
|
84
|
+
const path = hostIdentityPath(options, true);
|
|
85
|
+
if (!path)
|
|
86
|
+
return false;
|
|
87
|
+
if (existsSync(path)) {
|
|
88
|
+
const existing = readHostIdentity(options);
|
|
89
|
+
if (!existing || existing.endpoint_sha256 !== endpointSha256) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (existing.credential_id === credentialId) {
|
|
93
|
+
try {
|
|
94
|
+
if (options.owner) {
|
|
95
|
+
chownSync(path, options.owner.uid, options.owner.gid);
|
|
96
|
+
}
|
|
97
|
+
chmodSync(path, 0o600);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return replaceHostIdentity(path, {
|
|
105
|
+
version: HOST_IDENTITY_VERSION,
|
|
106
|
+
credential_id: credentialId,
|
|
107
|
+
endpoint_sha256: endpointSha256,
|
|
108
|
+
}, options.owner);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(path, JSON.stringify({
|
|
112
|
+
version: HOST_IDENTITY_VERSION,
|
|
113
|
+
credential_id: credentialId,
|
|
114
|
+
endpoint_sha256: endpointSha256,
|
|
115
|
+
}, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
116
|
+
if (options.owner) {
|
|
117
|
+
chownSync(path, options.owner.uid, options.owner.gid);
|
|
118
|
+
}
|
|
119
|
+
chmodSync(path, 0o600);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Another process may have won the create race. Only the same identity
|
|
124
|
+
// is accepted after re-reading the completed enrollment record.
|
|
125
|
+
const existing = readHostIdentity(options);
|
|
126
|
+
return (existing?.credential_id === credentialId &&
|
|
127
|
+
existing.endpoint_sha256 === endpointSha256);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function replaceHostIdentity(path, identity, owner) {
|
|
131
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
132
|
+
try {
|
|
133
|
+
writeFileSync(temporaryPath, JSON.stringify(identity, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
134
|
+
if (owner) {
|
|
135
|
+
chownSync(temporaryPath, owner.uid, owner.gid);
|
|
136
|
+
}
|
|
137
|
+
chmodSync(temporaryPath, 0o600);
|
|
138
|
+
renameSync(temporaryPath, path);
|
|
139
|
+
chmodSync(path, 0o600);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(temporaryPath);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Best effort.
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function matchesEnterpriseHostIdentity(credentialId, options = {}) {
|
|
153
|
+
if (!/^[a-f0-9]{64}$/.test(credentialId))
|
|
154
|
+
return false;
|
|
155
|
+
return readHostIdentity(options)?.credential_id === credentialId;
|
|
156
|
+
}
|
|
157
|
+
export function inspectEnterpriseHostIdentity(credentialId, endpoint, options = {}) {
|
|
158
|
+
if (!/^[a-f0-9]{64}$/.test(credentialId))
|
|
159
|
+
return "invalid";
|
|
160
|
+
let home;
|
|
161
|
+
try {
|
|
162
|
+
home = realpathSync(configuredHomeDir(options.homeDir));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return "invalid";
|
|
166
|
+
}
|
|
167
|
+
const dir = join(home, ".cortex");
|
|
168
|
+
if (!existsSync(dir))
|
|
169
|
+
return "unbound";
|
|
170
|
+
try {
|
|
171
|
+
const dirStat = lstatSync(dir);
|
|
172
|
+
if (!dirStat.isDirectory() ||
|
|
173
|
+
dirStat.isSymbolicLink() ||
|
|
174
|
+
realpathSync(dir) !== dir) {
|
|
175
|
+
return "invalid";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return "invalid";
|
|
180
|
+
}
|
|
181
|
+
const path = join(dir, HOST_IDENTITY_FILENAME);
|
|
182
|
+
if (!existsSync(path))
|
|
183
|
+
return "unbound";
|
|
184
|
+
const existing = readHostIdentity(options);
|
|
185
|
+
if (!existing)
|
|
186
|
+
return "invalid";
|
|
187
|
+
const endpointSha256 = createHash("sha256")
|
|
188
|
+
.update(endpoint.trim().replace(/\/+$/, ""), "utf8")
|
|
189
|
+
.digest("hex");
|
|
190
|
+
if (existing.endpoint_sha256 !== endpointSha256)
|
|
191
|
+
return "conflict";
|
|
192
|
+
return existing.credential_id === credentialId ? "match" : "rotation";
|
|
193
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadEnterpriseConfig } from "./config.js";
|
|
3
|
+
import { enterpriseCredentialId } from "./license.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the opaque per-user Enterprise identity for a project.
|
|
6
|
+
*
|
|
7
|
+
* The identifier binds the normalized endpoint and API key without
|
|
8
|
+
* persisting either credential in shared daemon metadata.
|
|
9
|
+
*/
|
|
10
|
+
export function configuredEnterpriseCredentialId(cwd) {
|
|
11
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
12
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
13
|
+
const endpoint = (config.enterprise.base_url ||
|
|
14
|
+
config.enterprise.endpoint).trim();
|
|
15
|
+
if (!apiKey || !endpoint)
|
|
16
|
+
return null;
|
|
17
|
+
return enterpriseCredentialId(endpoint, apiKey);
|
|
18
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmdirSync, unlinkSync, } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
|
|
5
|
+
const OWNERSHIP_FILENAME = ".cortex-managed.json";
|
|
6
|
+
const STATE_FILES = [
|
|
7
|
+
"skills.local.json",
|
|
8
|
+
"workflows.local.json",
|
|
9
|
+
"capabilities.local.json",
|
|
10
|
+
".skills-update-applied.json",
|
|
11
|
+
];
|
|
12
|
+
function configuredHome(homeDir) {
|
|
13
|
+
return homeDir ?? process.env.HOME?.trim() ?? homedir();
|
|
14
|
+
}
|
|
15
|
+
function safeDirectory(path) {
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return null;
|
|
18
|
+
const stat = lstatSync(path);
|
|
19
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
20
|
+
throw new Error(`${path} is not a safe directory`);
|
|
21
|
+
}
|
|
22
|
+
const real = realpathSync(path);
|
|
23
|
+
if (real !== path)
|
|
24
|
+
throw new Error(`${path} is not a canonical directory`);
|
|
25
|
+
return real;
|
|
26
|
+
}
|
|
27
|
+
function inspectManagedSkill(root, cli, name) {
|
|
28
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
29
|
+
throw new Error("invalid managed skill name");
|
|
30
|
+
}
|
|
31
|
+
const dir = resolve(root, name);
|
|
32
|
+
if (!dir.startsWith(`${root}/`)) {
|
|
33
|
+
throw new Error("managed skill escapes its root");
|
|
34
|
+
}
|
|
35
|
+
const stat = lstatSync(dir);
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
37
|
+
throw new Error("managed skill target is not a safe directory");
|
|
38
|
+
}
|
|
39
|
+
if (realpathSync(dir) !== dir) {
|
|
40
|
+
throw new Error("managed skill target is not canonical");
|
|
41
|
+
}
|
|
42
|
+
const marker = join(dir, OWNERSHIP_FILENAME);
|
|
43
|
+
if (!existsSync(marker))
|
|
44
|
+
return null;
|
|
45
|
+
const markerStat = lstatSync(marker);
|
|
46
|
+
if (!markerStat.isFile() || markerStat.isSymbolicLink()) {
|
|
47
|
+
throw new Error("managed skill ownership marker is unsafe");
|
|
48
|
+
}
|
|
49
|
+
const parsed = JSON.parse(readFileSync(marker, "utf8"));
|
|
50
|
+
if (parsed.version !== 1 ||
|
|
51
|
+
parsed.manager !== "cortex" ||
|
|
52
|
+
parsed.cli !== cli ||
|
|
53
|
+
parsed.name !== name) {
|
|
54
|
+
throw new Error("managed skill ownership marker is invalid");
|
|
55
|
+
}
|
|
56
|
+
const entries = readdirSync(dir);
|
|
57
|
+
const unexpected = entries.filter((entry) => entry !== OWNERSHIP_FILENAME && entry !== "SKILL.md");
|
|
58
|
+
if (unexpected.length > 0) {
|
|
59
|
+
throw new Error("managed skill directory contains unowned files");
|
|
60
|
+
}
|
|
61
|
+
const skill = join(dir, "SKILL.md");
|
|
62
|
+
if (existsSync(skill)) {
|
|
63
|
+
const skillStat = lstatSync(skill);
|
|
64
|
+
if (!skillStat.isFile() || skillStat.isSymbolicLink()) {
|
|
65
|
+
throw new Error("managed skill file is unsafe");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { dir, marker, skill: existsSync(skill) ? skill : null };
|
|
69
|
+
}
|
|
70
|
+
function recordedSkills(home) {
|
|
71
|
+
const statePath = join(home, ".cortex", "skills.local.json");
|
|
72
|
+
if (!existsSync(statePath))
|
|
73
|
+
return [];
|
|
74
|
+
const stat = lstatSync(statePath);
|
|
75
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
76
|
+
throw new Error("skills state is unsafe");
|
|
77
|
+
}
|
|
78
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8"));
|
|
79
|
+
if (!parsed.skills || typeof parsed.skills !== "object")
|
|
80
|
+
return [];
|
|
81
|
+
const records = [];
|
|
82
|
+
for (const [key, value] of Object.entries(parsed.skills)) {
|
|
83
|
+
const [keyCli, name] = key.includes(":")
|
|
84
|
+
? key.split(":", 2)
|
|
85
|
+
: [String(value?.cli ?? ""), key];
|
|
86
|
+
if ((keyCli !== "claude" && keyCli !== "codex") ||
|
|
87
|
+
!SKILL_NAME_RE.test(name)) {
|
|
88
|
+
throw new Error("skills state contains an unsafe record");
|
|
89
|
+
}
|
|
90
|
+
records.push({ cli: keyCli, name });
|
|
91
|
+
}
|
|
92
|
+
return records;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Purge credential-specific artifacts before an explicit API-key rotation.
|
|
96
|
+
* Every path is derived from the target user's home and every skill deletion
|
|
97
|
+
* requires the Cortex ownership marker. Any ambiguity aborts rotation.
|
|
98
|
+
*/
|
|
99
|
+
export function prepareEnterpriseCredentialRotation(homeDir) {
|
|
100
|
+
try {
|
|
101
|
+
const home = realpathSync(configuredHome(homeDir));
|
|
102
|
+
const cortexDir = safeDirectory(join(home, ".cortex"));
|
|
103
|
+
if (!cortexDir)
|
|
104
|
+
return true;
|
|
105
|
+
const targets = new Map();
|
|
106
|
+
const recorded = recordedSkills(home);
|
|
107
|
+
for (const cli of ["claude", "codex"]) {
|
|
108
|
+
const cliDir = safeDirectory(join(home, cli === "codex" ? ".codex" : ".claude"));
|
|
109
|
+
if (!cliDir) {
|
|
110
|
+
if (recorded.some((record) => record.cli === cli))
|
|
111
|
+
return false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const root = safeDirectory(join(cliDir, "skills"));
|
|
115
|
+
if (!root) {
|
|
116
|
+
if (recorded.some((record) => record.cli === cli))
|
|
117
|
+
return false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const name of readdirSync(root)) {
|
|
121
|
+
const candidate = join(root, name);
|
|
122
|
+
const stat = lstatSync(candidate);
|
|
123
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
124
|
+
continue;
|
|
125
|
+
const target = inspectManagedSkill(root, cli, name);
|
|
126
|
+
if (target)
|
|
127
|
+
targets.set(target.dir, target);
|
|
128
|
+
}
|
|
129
|
+
for (const record of recorded.filter((entry) => entry.cli === cli)) {
|
|
130
|
+
const candidate = join(root, record.name);
|
|
131
|
+
if (!existsSync(candidate))
|
|
132
|
+
continue;
|
|
133
|
+
const target = inspectManagedSkill(root, cli, record.name);
|
|
134
|
+
if (!target) {
|
|
135
|
+
// Legacy state without an ownership marker is not deletion
|
|
136
|
+
// authority. Abort and require the operator to resolve it.
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
targets.set(target.dir, target);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const target of targets.values()) {
|
|
143
|
+
if (target.skill)
|
|
144
|
+
unlinkSync(target.skill);
|
|
145
|
+
unlinkSync(target.marker);
|
|
146
|
+
rmdirSync(target.dir);
|
|
147
|
+
}
|
|
148
|
+
for (const filename of STATE_FILES) {
|
|
149
|
+
const path = join(cortexDir, filename);
|
|
150
|
+
if (existsSync(path))
|
|
151
|
+
unlinkSync(path);
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MANAGED_PATHS = {
|
|
2
|
+
claude: {
|
|
3
|
+
darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
4
|
+
linux: "/etc/claude-code/managed-settings.json",
|
|
5
|
+
},
|
|
6
|
+
codex: {
|
|
7
|
+
darwin: "/Library/Application Support/Codex/requirements.toml",
|
|
8
|
+
linux: "/etc/codex/requirements.toml",
|
|
9
|
+
},
|
|
10
|
+
copilot: {
|
|
11
|
+
darwin: "/usr/local/bin/copilot",
|
|
12
|
+
linux: "/usr/local/bin/copilot",
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export function getGovernManagedPath(cli, os) {
|
|
16
|
+
const path = MANAGED_PATHS[cli][os];
|
|
17
|
+
if (!path) {
|
|
18
|
+
throw new Error(`govern install for ${cli} not yet supported on ${os}`);
|
|
19
|
+
}
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Config
|
|
2
|
+
export { loadEnterpriseConfig, resolveEnterpriseActivation } from "./config.js";
|
|
3
|
+
// Telemetry
|
|
4
|
+
export { TelemetryCollector } from "./telemetry/collector.js";
|
|
5
|
+
// Audit
|
|
6
|
+
export { AuditWriter } from "./audit/writer.js";
|
|
7
|
+
export { queryAuditLog } from "./audit/query.js";
|
|
8
|
+
// RBAC
|
|
9
|
+
export { checkAccess, getAccessDeniedMessage } from "./rbac/check.js";
|
|
10
|
+
// Policy
|
|
11
|
+
export { PolicyStore } from "./policy/store.js";
|
|
12
|
+
// Prompt injection
|
|
13
|
+
export { scanForInjection, sanitizeContent } from "./policy/injection.js";
|
|
14
|
+
export { enforceInjectionPolicy, isInjectionDefenseActive, buildViolationPayload } from "./policy/enforce.js";
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync, } from "node:fs";
|
|
3
|
+
import { resolveTelemetryStateDir, telemetryStatePath } from "./telemetry/state-dir.js";
|
|
4
|
+
import { isAllowedEnterpriseEndpoint } from "./secure-endpoint.js";
|
|
5
|
+
export { isAllowedEnterpriseEndpoint as isAllowedLicenseEndpoint };
|
|
6
|
+
const CACHE_FILE = "license_cache.json";
|
|
7
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h fresh
|
|
8
|
+
const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7d grace if endpoint unreachable
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
10
|
+
function cachePath(contextDir) {
|
|
11
|
+
return telemetryStatePath(contextDir, CACHE_FILE);
|
|
12
|
+
}
|
|
13
|
+
function isIsoTimestamp(value) {
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
return false;
|
|
16
|
+
const timestamp = new Date(value).getTime();
|
|
17
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
|
|
18
|
+
}
|
|
19
|
+
function readCache(contextDir) {
|
|
20
|
+
const path = cachePath(contextDir);
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const raw = readFileSync(path, "utf8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed ||
|
|
27
|
+
typeof parsed !== "object" ||
|
|
28
|
+
parsed.version !== 2 ||
|
|
29
|
+
typeof parsed.endpoint !== "string" ||
|
|
30
|
+
!parsed.endpoint ||
|
|
31
|
+
typeof parsed.api_key_sha256 !== "string" ||
|
|
32
|
+
!/^[a-f0-9]{64}$/.test(parsed.api_key_sha256) ||
|
|
33
|
+
!parsed.result ||
|
|
34
|
+
typeof parsed.result !== "object" ||
|
|
35
|
+
typeof parsed.result.valid !== "boolean" ||
|
|
36
|
+
!isIsoTimestamp(parsed.cached_at)) {
|
|
37
|
+
deleteCache(contextDir);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (parsed.result.valid) {
|
|
41
|
+
if (typeof parsed.result.edition !== "string" ||
|
|
42
|
+
!Array.isArray(parsed.result.features) ||
|
|
43
|
+
!parsed.result.features.every((feature) => typeof feature === "string") ||
|
|
44
|
+
!isIsoTimestamp(parsed.result.expires_at) ||
|
|
45
|
+
typeof parsed.result.max_repos !== "number" ||
|
|
46
|
+
!Number.isFinite(parsed.result.max_repos) ||
|
|
47
|
+
parsed.result.max_repos < 0 ||
|
|
48
|
+
!isIsoTimestamp(parsed.result.verified_at) ||
|
|
49
|
+
!["remote", "cache"].includes(parsed.result.source)) {
|
|
50
|
+
deleteCache(contextDir);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (typeof parsed.result.reason !== "string" ||
|
|
55
|
+
!isIsoTimestamp(parsed.result.verified_at) ||
|
|
56
|
+
!["remote", "cache", "grace_expired"].includes(parsed.result.source)) {
|
|
57
|
+
deleteCache(contextDir);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
deleteCache(contextDir);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function writeCache(contextDir, entry) {
|
|
68
|
+
const path = cachePath(contextDir);
|
|
69
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(resolveTelemetryStateDir(contextDir), { recursive: true });
|
|
72
|
+
writeFileSync(temporaryPath, JSON.stringify(entry, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
73
|
+
chmodSync(temporaryPath, 0o600);
|
|
74
|
+
renameSync(temporaryPath, path);
|
|
75
|
+
chmodSync(path, 0o600);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(temporaryPath);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Best effort.
|
|
83
|
+
}
|
|
84
|
+
// Cache failures are non-fatal — license check just won't be cached.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function deleteCache(contextDir) {
|
|
88
|
+
const path = cachePath(contextDir);
|
|
89
|
+
if (!existsSync(path))
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
unlinkSync(path);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// ignore — best-effort
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function ageMs(isoTimestamp) {
|
|
99
|
+
return Date.now() - new Date(isoTimestamp).getTime();
|
|
100
|
+
}
|
|
101
|
+
export function normalizeLicenseEndpoint(endpoint) {
|
|
102
|
+
return endpoint.trim().replace(/\/+$/, "");
|
|
103
|
+
}
|
|
104
|
+
function apiKeyFingerprint(apiKey) {
|
|
105
|
+
return createHash("sha256").update(apiKey, "utf8").digest("hex");
|
|
106
|
+
}
|
|
107
|
+
function cacheMatchesCredential(cached, endpoint, apiKey) {
|
|
108
|
+
return (cached.version === 2 &&
|
|
109
|
+
cached.endpoint === normalizeLicenseEndpoint(endpoint) &&
|
|
110
|
+
cached.api_key_sha256 === apiKeyFingerprint(apiKey));
|
|
111
|
+
}
|
|
112
|
+
export function enterpriseCredentialId(endpoint, apiKey) {
|
|
113
|
+
return createHash("sha256")
|
|
114
|
+
.update(normalizeLicenseEndpoint(endpoint), "utf8")
|
|
115
|
+
.update("\0", "utf8")
|
|
116
|
+
.update(apiKey, "utf8")
|
|
117
|
+
.digest("hex");
|
|
118
|
+
}
|
|
119
|
+
function cacheResultIsUnexpired(cached) {
|
|
120
|
+
if (!cached.result.valid)
|
|
121
|
+
return false;
|
|
122
|
+
const expiresAt = new Date(cached.result.expires_at).getTime();
|
|
123
|
+
return Number.isFinite(expiresAt) && expiresAt > Date.now();
|
|
124
|
+
}
|
|
125
|
+
function cacheAgeIsWithin(cached, limitMs) {
|
|
126
|
+
const age = ageMs(cached.cached_at);
|
|
127
|
+
return Number.isFinite(age) && age >= 0 && age < limitMs;
|
|
128
|
+
}
|
|
129
|
+
async function fetchLicense(endpoint, apiKey, instanceId, clientVersion) {
|
|
130
|
+
const url = `${endpoint.replace(/\/$/, "")}/api/v1/license/verify`;
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(url, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
"content-type": "application/json",
|
|
138
|
+
authorization: `Bearer ${apiKey}`,
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
instance_id: instanceId,
|
|
142
|
+
client_version: clientVersion,
|
|
143
|
+
}),
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
});
|
|
146
|
+
if (res.status === 401 || res.status === 403) {
|
|
147
|
+
return {
|
|
148
|
+
valid: false,
|
|
149
|
+
reason: "authentication_rejected",
|
|
150
|
+
verified_at: new Date().toISOString(),
|
|
151
|
+
source: "remote",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
// Rate limiting, server failures, and other transport-level responses
|
|
156
|
+
// may use an identity-matched positive cache during the grace period.
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const json = (await res.json());
|
|
160
|
+
const verifiedAt = new Date().toISOString();
|
|
161
|
+
if (json.valid === true &&
|
|
162
|
+
typeof json.edition === "string" &&
|
|
163
|
+
Array.isArray(json.features) &&
|
|
164
|
+
json.features.every((feature) => typeof feature === "string") &&
|
|
165
|
+
isIsoTimestamp(json.expires_at) &&
|
|
166
|
+
new Date(json.expires_at).getTime() > Date.now() &&
|
|
167
|
+
typeof json.max_repos === "number" &&
|
|
168
|
+
Number.isFinite(json.max_repos) &&
|
|
169
|
+
json.max_repos >= 0) {
|
|
170
|
+
return {
|
|
171
|
+
valid: true,
|
|
172
|
+
edition: json.edition,
|
|
173
|
+
features: json.features,
|
|
174
|
+
expires_at: json.expires_at,
|
|
175
|
+
max_repos: json.max_repos,
|
|
176
|
+
verified_at: verifiedAt,
|
|
177
|
+
source: "remote",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
valid: false,
|
|
182
|
+
reason: json.valid === true
|
|
183
|
+
? "malformed_license_response"
|
|
184
|
+
: String(json.reason ?? "unknown"),
|
|
185
|
+
verified_at: verifiedAt,
|
|
186
|
+
source: "remote",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Network error, timeout, JSON parse error — treat as transient.
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timeout);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Verify the license for the given api_key. Layered fallback:
|
|
199
|
+
* 1. If a positive cache (valid:true) is fresh (<24h), unexpired, and
|
|
200
|
+
* bound to this endpoint + API-key fingerprint → use cache
|
|
201
|
+
* Negative cache entries are never trusted; if one is encountered
|
|
202
|
+
* it's deleted on the spot so a since-fixed remote can heal.
|
|
203
|
+
* 2. Otherwise try remote endpoint
|
|
204
|
+
* - On valid:true → write positive cache, return result
|
|
205
|
+
* - On valid:false (authoritative fail) → DELETE any positive
|
|
206
|
+
* cache (so a revoked/expired key doesn't keep masquerading as
|
|
207
|
+
* valid past its remote-side fail), return result, do NOT
|
|
208
|
+
* cache the negative.
|
|
209
|
+
* - On transient failure → fall back to the same identity-bound positive
|
|
210
|
+
* cache if within the grace period (7d). Authentication rejection is
|
|
211
|
+
* authoritative and never uses grace.
|
|
212
|
+
* 3. If no usable cache and endpoint unreachable → return invalid
|
|
213
|
+
* (grace_expired).
|
|
214
|
+
*
|
|
215
|
+
* The caller decides what to do based on the result. Typically:
|
|
216
|
+
* - valid:true → activate enterprise hooks
|
|
217
|
+
* - valid:false → community mode (no enterprise)
|
|
218
|
+
*/
|
|
219
|
+
export async function verifyLicense(contextDir, endpoint, apiKey, options = {}) {
|
|
220
|
+
if (!isAllowedEnterpriseEndpoint(endpoint)) {
|
|
221
|
+
deleteCache(contextDir);
|
|
222
|
+
return {
|
|
223
|
+
valid: false,
|
|
224
|
+
reason: "insecure_or_invalid_endpoint",
|
|
225
|
+
verified_at: new Date().toISOString(),
|
|
226
|
+
source: "remote",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
let cached = readCache(contextDir);
|
|
230
|
+
// Defensive: a previous version of this code wrote negative results
|
|
231
|
+
// into the cache. Refuse to honour them and clean them up so a
|
|
232
|
+
// since-deployed fix on the remote can be observed.
|
|
233
|
+
if (cached && cached.result.valid === false) {
|
|
234
|
+
deleteCache(contextDir);
|
|
235
|
+
cached = null;
|
|
236
|
+
}
|
|
237
|
+
const matchingCache = cached && cacheMatchesCredential(cached, endpoint, apiKey)
|
|
238
|
+
? cached
|
|
239
|
+
: null;
|
|
240
|
+
// Fresh positive cache: skip remote.
|
|
241
|
+
if (matchingCache &&
|
|
242
|
+
matchingCache.result.valid === true &&
|
|
243
|
+
cacheResultIsUnexpired(matchingCache) &&
|
|
244
|
+
cacheAgeIsWithin(matchingCache, CACHE_TTL_MS)) {
|
|
245
|
+
return { ...matchingCache.result, source: "cache" };
|
|
246
|
+
}
|
|
247
|
+
const remote = await fetchLicense(endpoint, apiKey, options.instance_id, options.client_version);
|
|
248
|
+
if (remote) {
|
|
249
|
+
if (remote.valid) {
|
|
250
|
+
writeCache(contextDir, {
|
|
251
|
+
version: 2,
|
|
252
|
+
endpoint: normalizeLicenseEndpoint(endpoint),
|
|
253
|
+
api_key_sha256: apiKeyFingerprint(apiKey),
|
|
254
|
+
result: remote,
|
|
255
|
+
cached_at: new Date().toISOString(),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// Authoritative fail from remote — drop any stale positive cache
|
|
260
|
+
// so we don't bounce back to "valid" on the next call.
|
|
261
|
+
deleteCache(contextDir);
|
|
262
|
+
}
|
|
263
|
+
return remote;
|
|
264
|
+
}
|
|
265
|
+
// Remote unreachable. Fall back to positive cache if within grace.
|
|
266
|
+
if (matchingCache &&
|
|
267
|
+
matchingCache.result.valid === true &&
|
|
268
|
+
cacheResultIsUnexpired(matchingCache) &&
|
|
269
|
+
cacheAgeIsWithin(matchingCache, GRACE_PERIOD_MS)) {
|
|
270
|
+
return { ...matchingCache.result, source: "cache" };
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
valid: false,
|
|
274
|
+
reason: "endpoint_unreachable_grace_expired",
|
|
275
|
+
verified_at: new Date().toISOString(),
|
|
276
|
+
source: "grace_expired",
|
|
277
|
+
};
|
|
278
|
+
}
|