@getmarrow/install 0.1.65 → 0.1.67
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/README.md +81 -22
- package/package.json +1 -1
- package/src/controller-manager.js +152 -6
- package/src/enforcement-client.js +6 -3
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +6 -0
- package/src/governed-runner.js +413 -90
- package/src/hermes-config.js +568 -0
- package/src/installer.js +767 -376
- package/src/owner-env.js +134 -0
package/src/owner-env.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
// ~/.marrow/env is the owner-only key store that the Marrow MCP server, SDK hooks and this
|
|
6
|
+
// installer already read. Values are parsed in memory and never printed or logged.
|
|
7
|
+
const OWNER_ENV_FILES = ['env.local', 'env'];
|
|
8
|
+
const KEY_NAMES = ['MARROW_API_KEY', 'MARROW_KEY'];
|
|
9
|
+
|
|
10
|
+
function currentUid() {
|
|
11
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPrivateOwnerFile(filePath, home) {
|
|
15
|
+
try {
|
|
16
|
+
const resolvedHome = path.resolve(home);
|
|
17
|
+
const resolvedFile = path.resolve(filePath);
|
|
18
|
+
const parent = path.dirname(resolvedFile);
|
|
19
|
+
const homeStat = fs.lstatSync(resolvedHome);
|
|
20
|
+
const parentStat = fs.lstatSync(parent);
|
|
21
|
+
const fileStat = fs.lstatSync(resolvedFile);
|
|
22
|
+
const uid = currentUid();
|
|
23
|
+
return resolvedFile.startsWith(`${resolvedHome}${path.sep}`)
|
|
24
|
+
&& homeStat.isDirectory() && !homeStat.isSymbolicLink() && fs.realpathSync(resolvedHome) === resolvedHome
|
|
25
|
+
&& parentStat.isDirectory() && !parentStat.isSymbolicLink() && fs.realpathSync(parent) === parent
|
|
26
|
+
&& fileStat.isFile() && !fileStat.isSymbolicLink()
|
|
27
|
+
&& (uid === null || (fileStat.uid === uid && parentStat.uid === uid && homeStat.uid === uid))
|
|
28
|
+
&& (fileStat.mode & 0o077) === 0
|
|
29
|
+
&& (parentStat.mode & 0o022) === 0
|
|
30
|
+
&& (homeStat.mode & 0o022) === 0;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripQuotes(value) {
|
|
37
|
+
const trimmed = String(value || '').trim();
|
|
38
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
39
|
+
return trimmed.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
return trimmed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseKeyLines(raw) {
|
|
45
|
+
const values = {};
|
|
46
|
+
for (const line of String(raw || '').split(/\r?\n/)) {
|
|
47
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
48
|
+
if (!match || !KEY_NAMES.includes(match[1])) continue;
|
|
49
|
+
let value = match[2] || '';
|
|
50
|
+
const hashIndex = value.search(/\s+#/);
|
|
51
|
+
if (hashIndex >= 0) value = value.slice(0, hashIndex);
|
|
52
|
+
value = stripQuotes(value);
|
|
53
|
+
if (value) values[match[1]] = value;
|
|
54
|
+
}
|
|
55
|
+
return values;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fingerprintMatches(left, right) {
|
|
59
|
+
const digest = (value) => crypto.createHash('sha256').update(String(value)).digest();
|
|
60
|
+
return crypto.timingSafeEqual(digest(left), digest(right));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Returns the stored key only from a private, owner-owned file, as the MCP trusted reader does:
|
|
64
|
+
// env.local first, then env. `conflict` is true when both hold a key and they differ; the keys
|
|
65
|
+
// are compared in memory only and never returned for display.
|
|
66
|
+
function readOwnerApiKey(home) {
|
|
67
|
+
const directory = path.join(home, '.marrow');
|
|
68
|
+
const found = [];
|
|
69
|
+
for (const name of OWNER_ENV_FILES) {
|
|
70
|
+
const filePath = path.join(directory, name);
|
|
71
|
+
if (!fs.existsSync(filePath) || !isPrivateOwnerFile(filePath, home)) continue;
|
|
72
|
+
try {
|
|
73
|
+
const values = parseKeyLines(fs.readFileSync(filePath, 'utf8'));
|
|
74
|
+
const apiKey = values.MARROW_API_KEY || values.MARROW_KEY || '';
|
|
75
|
+
if (apiKey) found.push({ apiKey, source: filePath });
|
|
76
|
+
} catch {
|
|
77
|
+
// An unreadable owner file is treated as absent.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (found.length === 0) return { apiKey: '', source: null, conflict: false };
|
|
81
|
+
const conflict = found.length > 1 && !fingerprintMatches(found[0].apiKey, found[1].apiKey);
|
|
82
|
+
return { apiKey: found[0].apiKey, source: found[0].source, conflict };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Stores the key only when no owner key file exists yet. An existing different key is left
|
|
86
|
+
// unchanged and reported, never replaced. Writes are private (directory 0700, file 0600).
|
|
87
|
+
function ensureOwnerApiKey(home, apiKey) {
|
|
88
|
+
const key = String(apiKey || '').trim();
|
|
89
|
+
const directory = path.join(home, '.marrow');
|
|
90
|
+
const filePath = path.join(directory, 'env');
|
|
91
|
+
if (!key) return { state: 'no_key', path: filePath, written: false };
|
|
92
|
+
const stored = readOwnerApiKey(home);
|
|
93
|
+
if (stored.apiKey) {
|
|
94
|
+
return {
|
|
95
|
+
state: fingerprintMatches(stored.apiKey, key) ? 'present' : 'different_key_present',
|
|
96
|
+
path: stored.source,
|
|
97
|
+
written: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
for (const name of OWNER_ENV_FILES) {
|
|
101
|
+
const candidate = path.join(directory, name);
|
|
102
|
+
if (fs.existsSync(candidate) && !isPrivateOwnerFile(candidate, home)) {
|
|
103
|
+
return { state: 'unsafe_existing_file', path: candidate, written: false };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const homeStat = fs.lstatSync(home);
|
|
107
|
+
if (!homeStat.isDirectory() || homeStat.isSymbolicLink() || (homeStat.mode & 0o022) !== 0) {
|
|
108
|
+
return { state: 'unsafe_home', path: filePath, written: false };
|
|
109
|
+
}
|
|
110
|
+
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
|
|
111
|
+
const directoryStat = fs.lstatSync(directory);
|
|
112
|
+
const uid = currentUid();
|
|
113
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()
|
|
114
|
+
|| (uid !== null && directoryStat.uid !== uid) || (directoryStat.mode & 0o022) !== 0) {
|
|
115
|
+
return { state: 'unsafe_directory', path: filePath, written: false };
|
|
116
|
+
}
|
|
117
|
+
const before = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
118
|
+
const separator = before && !before.endsWith('\n') ? '\n' : '';
|
|
119
|
+
const tempPath = path.join(directory, `.env.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(tempPath, `${before}${separator}MARROW_API_KEY=${key}\n`, { flag: 'wx', mode: 0o600 });
|
|
122
|
+
fs.renameSync(tempPath, filePath);
|
|
123
|
+
fs.chmodSync(filePath, 0o600);
|
|
124
|
+
} finally {
|
|
125
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
126
|
+
}
|
|
127
|
+
return { state: 'written', path: filePath, written: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
ensureOwnerApiKey,
|
|
132
|
+
isPrivateOwnerFile,
|
|
133
|
+
readOwnerApiKey,
|
|
134
|
+
};
|