hop2 0.11.47 → 0.11.49
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/LICENSE +21 -0
- package/README-MCP.md +3 -2
- package/README.md +12 -6
- package/SECURITY.md +102 -0
- package/hop +488 -86
- package/lib/audit-rotation.js +113 -0
- package/lib/auth-sessions.js +194 -0
- package/lib/registration.js +9 -6
- package/mcp/hop-mcp.js +49 -5
- package/package.json +1 -1
- package/tests/audit-rotation.unit.test.cjs +74 -0
- package/tests/auth-sessions.integration.test.cjs +315 -0
- package/tests/auth-sessions.unit.test.cjs +96 -0
- package/tests/hop-mcp.integration.test.cjs +4 -4
- package/tests/registration.integration.test.cjs +3 -1
- package/tests/registration.unit.test.cjs +14 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Size rotation and age-based retention for per-session audit logs.
|
|
2
|
+
//
|
|
3
|
+
// Each hop session writes `<logsDir>/<session>/audit.ndjson`: every keystroke
|
|
4
|
+
// and every byte of output, forever. Nothing ever trimmed it. A chatty agent
|
|
5
|
+
// session left running for weeks grows to gigabytes, and a session that
|
|
6
|
+
// ended a year ago still has its full transcript on disk. Two limits:
|
|
7
|
+
//
|
|
8
|
+
// - SIZE: when the live file passes maxBytes it is renamed to
|
|
9
|
+
// audit.ndjson.<utc-timestamp> and a fresh file starts; only the newest
|
|
10
|
+
// `keep` rotated files survive.
|
|
11
|
+
// - AGE: a session directory whose newest file is older than retentionMs,
|
|
12
|
+
// and whose session is not live, is deleted whole.
|
|
13
|
+
//
|
|
14
|
+
// Pure functions over the filesystem so they can be unit-tested without the
|
|
15
|
+
// daemon.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const AUDIT_FILE_NAME = 'audit.ndjson';
|
|
21
|
+
|
|
22
|
+
function rotatedName(now = Date.now()) {
|
|
23
|
+
const stamp = new Date(now).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
24
|
+
return `${AUDIT_FILE_NAME}.${stamp}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rename the live audit file aside and prune old rotations.
|
|
29
|
+
* Caller must have closed its write stream first.
|
|
30
|
+
* @returns {{ rotatedTo: string|null, pruned: string[] }}
|
|
31
|
+
*/
|
|
32
|
+
function rotateAuditFile(filePath, { keep = 5, now = Date.now() } = {}) {
|
|
33
|
+
const dir = path.dirname(filePath);
|
|
34
|
+
let rotatedTo = null;
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
|
|
37
|
+
let target = path.join(dir, rotatedName(now));
|
|
38
|
+
// Two rotations inside one second: keep both.
|
|
39
|
+
let n = 1;
|
|
40
|
+
while (fs.existsSync(target)) target = path.join(dir, `${rotatedName(now)}.${n++}`);
|
|
41
|
+
fs.renameSync(filePath, target);
|
|
42
|
+
rotatedTo = target;
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { rotatedTo: null, pruned: [] };
|
|
46
|
+
}
|
|
47
|
+
const pruned = [];
|
|
48
|
+
try {
|
|
49
|
+
const rotated = fs.readdirSync(dir)
|
|
50
|
+
.filter((name) => name.startsWith(`${AUDIT_FILE_NAME}.`))
|
|
51
|
+
.map((name) => {
|
|
52
|
+
const full = path.join(dir, name);
|
|
53
|
+
let mtime = 0;
|
|
54
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch (e) { }
|
|
55
|
+
return { full, mtime, name };
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
|
|
58
|
+
for (const extra of rotated.slice(Math.max(0, keep))) {
|
|
59
|
+
try { fs.unlinkSync(extra.full); pruned.push(extra.full); } catch (e) { }
|
|
60
|
+
}
|
|
61
|
+
} catch (e) { }
|
|
62
|
+
return { rotatedTo, pruned };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function newestMtime(dir) {
|
|
66
|
+
let newest = 0;
|
|
67
|
+
try {
|
|
68
|
+
for (const name of fs.readdirSync(dir)) {
|
|
69
|
+
try {
|
|
70
|
+
const st = fs.statSync(path.join(dir, name));
|
|
71
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
72
|
+
} catch (e) { }
|
|
73
|
+
}
|
|
74
|
+
if (newest === 0) newest = fs.statSync(dir).mtimeMs;
|
|
75
|
+
} catch (e) { }
|
|
76
|
+
return newest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete per-session log directories that are stale.
|
|
81
|
+
* @param {string[]} logsRoots directories whose children are per-session dirs
|
|
82
|
+
* @param {object} options
|
|
83
|
+
* @param {number} options.retentionMs age after which a non-live dir goes
|
|
84
|
+
* @param {(sessionName: string) => boolean} options.isLive
|
|
85
|
+
* @param {number} [options.now]
|
|
86
|
+
* @returns {{ removed: string[], kept: number }}
|
|
87
|
+
*/
|
|
88
|
+
function sweepStaleAuditDirs(logsRoots, { retentionMs, isLive = () => false, now = Date.now() } = {}) {
|
|
89
|
+
const removed = [];
|
|
90
|
+
let kept = 0;
|
|
91
|
+
if (!Number.isFinite(retentionMs) || retentionMs <= 0) return { removed, kept };
|
|
92
|
+
for (const root of logsRoots) {
|
|
93
|
+
let entries = [];
|
|
94
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { continue; }
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (!entry.isDirectory()) continue;
|
|
97
|
+
const sessionName = entry.name;
|
|
98
|
+
const dir = path.join(root, sessionName);
|
|
99
|
+
if (isLive(sessionName)) { kept++; continue; }
|
|
100
|
+
const age = now - newestMtime(dir);
|
|
101
|
+
if (age < retentionMs) { kept++; continue; }
|
|
102
|
+
try {
|
|
103
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
removed.push(dir);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
kept++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { removed, kept };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { rotateAuditFile, sweepStaleAuditDirs, rotatedName, AUDIT_FILE_NAME };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Per-device login sessions for the hop daemon.
|
|
2
|
+
//
|
|
3
|
+
// Before this module every browser, phone and script that ever logged in
|
|
4
|
+
// held the same credential: the daemon's process-wide session secret, minted
|
|
5
|
+
// straight into the cookie. One value for every device meant nothing could
|
|
6
|
+
// be revoked short of rotating the secret and logging everyone out.
|
|
7
|
+
//
|
|
8
|
+
// Now each successful login (TOTP, password+TOTP, passkey) mints its own
|
|
9
|
+
// random token. Only a SHA-256 hash is stored, so the state file cannot be
|
|
10
|
+
// replayed if it leaks; the raw token lives in the client's cookie (or, for
|
|
11
|
+
// device tokens minted from the CLI, wherever the operator pastes it). Each
|
|
12
|
+
// session carries a label, how it was created, and an expiry — 30 days by
|
|
13
|
+
// default, so a phone that logs in once a month stays signed in — and any
|
|
14
|
+
// session can be listed and revoked on its own.
|
|
15
|
+
//
|
|
16
|
+
// The daemon's own secret is still accepted as a Bearer token for local
|
|
17
|
+
// hop-to-hop IPC (CLI, MCP, hay). It is never a cookie any more.
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const TOKEN_PREFIX = 'hs_';
|
|
25
|
+
const ID_PREFIX = 'as_';
|
|
26
|
+
// How often verify() persists lastSeenAt. Every request would turn each
|
|
27
|
+
// keystroke's WebSocket poll into a disk write.
|
|
28
|
+
const LAST_SEEN_WRITE_INTERVAL_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
function hashToken(token) {
|
|
31
|
+
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isSessionToken(value) {
|
|
35
|
+
return typeof value === 'string' && value.startsWith(TOKEN_PREFIX) && value.length >= 40 && value.length <= 128;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function timingSafeEqualHex(a, b) {
|
|
39
|
+
const ab = Buffer.from(String(a), 'hex');
|
|
40
|
+
const bb = Buffer.from(String(b), 'hex');
|
|
41
|
+
if (ab.length !== bb.length || ab.length === 0) return false;
|
|
42
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class AuthSessionStore {
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} filePath JSON state file (written 0600)
|
|
48
|
+
* @param {object} [options]
|
|
49
|
+
* @param {number} [options.ttlMs] default lifetime for new sessions
|
|
50
|
+
* @param {() => number} [options.now] clock, for tests
|
|
51
|
+
*/
|
|
52
|
+
constructor(filePath, options = {}) {
|
|
53
|
+
this.filePath = filePath;
|
|
54
|
+
this.ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : DEFAULT_TTL_MS;
|
|
55
|
+
this.now = typeof options.now === 'function' ? options.now : Date.now;
|
|
56
|
+
this._cache = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_load() {
|
|
60
|
+
if (this._cache) return this._cache;
|
|
61
|
+
let sessions = [];
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
|
64
|
+
if (Array.isArray(parsed?.sessions)) {
|
|
65
|
+
sessions = parsed.sessions.filter((s) => s && typeof s.id === 'string' && typeof s.tokenHash === 'string');
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
sessions = [];
|
|
69
|
+
}
|
|
70
|
+
this._cache = { sessions };
|
|
71
|
+
return this._cache;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_save() {
|
|
75
|
+
const state = this._load();
|
|
76
|
+
const dir = path.dirname(this.filePath);
|
|
77
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (e) { }
|
|
78
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
79
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, sessions: state.sessions }, null, 2), { mode: 0o600 });
|
|
80
|
+
fs.renameSync(tmp, this.filePath);
|
|
81
|
+
try { fs.chmodSync(this.filePath, 0o600); } catch (e) { }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Drop expired sessions. Returns how many were removed. */
|
|
85
|
+
prune({ save = true } = {}) {
|
|
86
|
+
const state = this._load();
|
|
87
|
+
const now = this.now();
|
|
88
|
+
const before = state.sessions.length;
|
|
89
|
+
state.sessions = state.sessions.filter((s) => !(Number.isFinite(s.expiresAt) && s.expiresAt <= now));
|
|
90
|
+
const removed = before - state.sessions.length;
|
|
91
|
+
if (removed > 0 && save) this._save();
|
|
92
|
+
return removed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mint a new session. The raw token is returned exactly once.
|
|
97
|
+
* @param {object} [meta] { label, via, ip, userAgent, ttlMs }
|
|
98
|
+
*/
|
|
99
|
+
create(meta = {}) {
|
|
100
|
+
const state = this._load();
|
|
101
|
+
this.prune({ save: false });
|
|
102
|
+
const now = this.now();
|
|
103
|
+
const ttl = Number.isFinite(meta.ttlMs) && meta.ttlMs > 0 ? meta.ttlMs : this.ttlMs;
|
|
104
|
+
const token = TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
|
|
105
|
+
const session = {
|
|
106
|
+
id: ID_PREFIX + crypto.randomBytes(6).toString('hex'),
|
|
107
|
+
tokenHash: hashToken(token),
|
|
108
|
+
label: String(meta.label || '').slice(0, 80) || null,
|
|
109
|
+
via: String(meta.via || 'unknown').slice(0, 32),
|
|
110
|
+
ip: meta.ip ? String(meta.ip).slice(0, 64) : null,
|
|
111
|
+
userAgent: meta.userAgent ? String(meta.userAgent).slice(0, 200) : null,
|
|
112
|
+
createdAt: now,
|
|
113
|
+
lastSeenAt: now,
|
|
114
|
+
// null = never expires (device tokens for automation, created explicitly).
|
|
115
|
+
expiresAt: meta.noExpiry === true ? null : now + ttl
|
|
116
|
+
};
|
|
117
|
+
state.sessions.push(session);
|
|
118
|
+
this._save();
|
|
119
|
+
return { id: session.id, token, expiresAt: session.expiresAt };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Look a raw token up. Returns the public view of the session or null.
|
|
124
|
+
* Refreshes lastSeenAt (persisted at most every few minutes).
|
|
125
|
+
*/
|
|
126
|
+
verify(token) {
|
|
127
|
+
if (!isSessionToken(token)) return null;
|
|
128
|
+
const state = this._load();
|
|
129
|
+
const hash = hashToken(token);
|
|
130
|
+
const now = this.now();
|
|
131
|
+
for (const s of state.sessions) {
|
|
132
|
+
if (!timingSafeEqualHex(s.tokenHash, hash)) continue;
|
|
133
|
+
if (Number.isFinite(s.expiresAt) && s.expiresAt <= now) {
|
|
134
|
+
this.prune();
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isFinite(s.lastSeenAt) || now - s.lastSeenAt >= LAST_SEEN_WRITE_INTERVAL_MS) {
|
|
138
|
+
s.lastSeenAt = now;
|
|
139
|
+
try { this._save(); } catch (e) { }
|
|
140
|
+
}
|
|
141
|
+
return this._public(s);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Revoke one session by id. */
|
|
147
|
+
revoke(id) {
|
|
148
|
+
const state = this._load();
|
|
149
|
+
const before = state.sessions.length;
|
|
150
|
+
state.sessions = state.sessions.filter((s) => s.id !== String(id));
|
|
151
|
+
const removed = before - state.sessions.length;
|
|
152
|
+
if (removed > 0) this._save();
|
|
153
|
+
return removed > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Revoke every session, optionally keeping one (the caller's own). */
|
|
157
|
+
revokeAll({ exceptId = null } = {}) {
|
|
158
|
+
const state = this._load();
|
|
159
|
+
const before = state.sessions.length;
|
|
160
|
+
state.sessions = state.sessions.filter((s) => exceptId && s.id === exceptId);
|
|
161
|
+
const removed = before - state.sessions.length;
|
|
162
|
+
if (removed > 0) this._save();
|
|
163
|
+
return removed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Every live session, newest first, without token material. */
|
|
167
|
+
list() {
|
|
168
|
+
this.prune();
|
|
169
|
+
return this._load().sessions
|
|
170
|
+
.map((s) => this._public(s))
|
|
171
|
+
.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_public(s) {
|
|
175
|
+
return {
|
|
176
|
+
id: s.id,
|
|
177
|
+
label: s.label,
|
|
178
|
+
via: s.via,
|
|
179
|
+
ip: s.ip,
|
|
180
|
+
userAgent: s.userAgent,
|
|
181
|
+
createdAt: s.createdAt,
|
|
182
|
+
lastSeenAt: s.lastSeenAt,
|
|
183
|
+
expiresAt: s.expiresAt
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
AuthSessionStore,
|
|
190
|
+
hashToken,
|
|
191
|
+
isSessionToken,
|
|
192
|
+
DEFAULT_TTL_MS,
|
|
193
|
+
TOKEN_PREFIX
|
|
194
|
+
};
|
package/lib/registration.js
CHANGED
|
@@ -18,9 +18,10 @@ const crypto = require('crypto');
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
20
|
|
|
21
|
-
// Who may register
|
|
22
|
-
//
|
|
23
|
-
|
|
21
|
+
// Who may register is the admin's call (`hop registration domain <d>`); there
|
|
22
|
+
// is deliberately no built-in default. Subdomains of the configured domain
|
|
23
|
+
// count (cs.example.edu under example.edu), because plenty of departments
|
|
24
|
+
// hand out mail there.
|
|
24
25
|
|
|
25
26
|
// Names that must never become someone's terminal. Two families: hostnames
|
|
26
27
|
// infrastructure assumes it owns (www, mail, ns1) and the addresses CAs and
|
|
@@ -102,7 +103,10 @@ function validateSubdomain(raw, { reserved = RESERVED_SUBDOMAINS } = {}) {
|
|
|
102
103
|
* allowing tags would let one person hold unlimited subdomains, and
|
|
103
104
|
* silently rewriting the address the user typed is worse than saying no.
|
|
104
105
|
*/
|
|
105
|
-
function validateEmail(raw, { allowedDomain =
|
|
106
|
+
function validateEmail(raw, { allowedDomain = null } = {}) {
|
|
107
|
+
if (!allowedDomain || !String(allowedDomain).trim()) {
|
|
108
|
+
return { ok: false, error: 'Registration is not open: no email domain has been configured.' };
|
|
109
|
+
}
|
|
106
110
|
const value = String(raw || '').trim().toLowerCase();
|
|
107
111
|
if (!value) return { ok: false, error: 'Enter your email address.' };
|
|
108
112
|
if (value.length > 254) return { ok: false, error: 'That address is too long.' };
|
|
@@ -133,7 +137,7 @@ class RegistrationStore {
|
|
|
133
137
|
*/
|
|
134
138
|
constructor(filePath, options = {}) {
|
|
135
139
|
this.filePath = filePath;
|
|
136
|
-
this.allowedDomain = options.allowedDomain ||
|
|
140
|
+
this.allowedDomain = options.allowedDomain || null;
|
|
137
141
|
this.reserved = options.reserved || RESERVED_SUBDOMAINS;
|
|
138
142
|
this.takenSubdomains = options.takenSubdomains || (() => []);
|
|
139
143
|
}
|
|
@@ -363,7 +367,6 @@ module.exports = {
|
|
|
363
367
|
newToken,
|
|
364
368
|
tokenMatches,
|
|
365
369
|
RESERVED_SUBDOMAINS,
|
|
366
|
-
DEFAULT_ALLOWED_EMAIL_DOMAIN,
|
|
367
370
|
VERIFY_TOKEN_TTL_MS,
|
|
368
371
|
CLAIM_TOKEN_TTL_MS
|
|
369
372
|
};
|
package/mcp/hop-mcp.js
CHANGED
|
@@ -34,15 +34,47 @@ const CLAUDE_PERMISSION_MODES = new Set([
|
|
|
34
34
|
'acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'manual', 'plan'
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
|
+
// Least → most autonomous. The host operator can cap what a spawned worker
|
|
38
|
+
// may run with (config.json "agent_permission_ceiling", or the env var
|
|
39
|
+
// HOP_AGENT_PERMISSION_CEILING; `hop config agent-ceiling <mode>`). Requests
|
|
40
|
+
// above the cap are clamped, never refused, so orchestration keeps working on
|
|
41
|
+
// a locked-down host — the worker just asks more. Default is the historical
|
|
42
|
+
// behaviour (bypassPermissions): MCP access already implies shell access on
|
|
43
|
+
// this machine, so the ceiling is a safety rail against accidents, not a
|
|
44
|
+
// security boundary against an attacker who holds the daemon secret.
|
|
45
|
+
const CLAUDE_PERMISSION_RANK = { plan: 0, manual: 1, acceptEdits: 2, dontAsk: 3, auto: 3, bypassPermissions: 4 };
|
|
46
|
+
const DEFAULT_AGENT_PERMISSION_CEILING = 'bypassPermissions';
|
|
47
|
+
|
|
48
|
+
function readAgentPermissionCeiling() {
|
|
49
|
+
const fromEnv = String(process.env.HOP_AGENT_PERMISSION_CEILING || '').trim();
|
|
50
|
+
if (CLAUDE_PERMISSION_MODES.has(fromEnv)) return fromEnv;
|
|
51
|
+
try {
|
|
52
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(resolveHomeDir(), '.config.json'), 'utf8'));
|
|
53
|
+
if (CLAUDE_PERMISSION_MODES.has(cfg.agent_permission_ceiling)) return cfg.agent_permission_ceiling;
|
|
54
|
+
} catch (e) { }
|
|
55
|
+
return DEFAULT_AGENT_PERMISSION_CEILING;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clampPermissionMode(requested, ceiling) {
|
|
59
|
+
const want = CLAUDE_PERMISSION_MODES.has(requested) ? requested : DEFAULT_AGENT_PERMISSION_CEILING;
|
|
60
|
+
const cap = CLAUDE_PERMISSION_MODES.has(ceiling) ? ceiling : DEFAULT_AGENT_PERMISSION_CEILING;
|
|
61
|
+
return CLAUDE_PERMISSION_RANK[want] > CLAUDE_PERMISSION_RANK[cap] ? cap : want;
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
function claudePresetCommand() {
|
|
38
65
|
return `env ${CLAUDE_PRESET_UNSET_ENV.map((name) => `-u ${name}`).join(' ')} claude`;
|
|
39
66
|
}
|
|
40
67
|
|
|
41
|
-
function codexPresetCommand() {
|
|
68
|
+
function codexPresetCommand(ceiling = DEFAULT_AGENT_PERMISSION_CEILING) {
|
|
42
69
|
// Managed workers cannot answer an interactive workspace-trust or approval
|
|
43
70
|
// prompt. `command` bypasses a same-named interactive-shell function so the
|
|
44
71
|
// autonomous flag is applied exactly once; the config override also keeps a
|
|
45
72
|
// self-update chooser from intercepting the first delegated task.
|
|
73
|
+
// Below the top ceiling Codex keeps its sandbox and asks on request instead
|
|
74
|
+
// of bypassing approvals outright.
|
|
75
|
+
if (ceiling !== 'bypassPermissions') {
|
|
76
|
+
return 'command codex --full-auto -c check_for_update_on_startup=false';
|
|
77
|
+
}
|
|
46
78
|
return 'command codex --dangerously-bypass-approvals-and-sandbox -c check_for_update_on_startup=false';
|
|
47
79
|
}
|
|
48
80
|
|
|
@@ -3603,7 +3635,7 @@ class HopMCPServer {
|
|
|
3603
3635
|
agent: { type: 'string', enum: ['claude', 'codex', 'gemini', 'custom'], description: 'Agent CLI preset (default: claude, launched with the Opus model alias — override via HOP_SPAWN_CLAUDE_MODEL or args — in autonomous permission mode). Use custom with command=... for anything else.' },
|
|
3604
3636
|
command: { type: 'string', description: 'Override the launch command (required when agent=custom).' },
|
|
3605
3637
|
args: { type: 'string', description: 'Extra CLI arguments appended to the launch command (e.g. "--model opus").' },
|
|
3606
|
-
permission_mode: { type: 'string', enum: ['acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'manual', 'plan'], description: 'Claude worker permission mode (default: bypassPermissions so delegated tasks cannot park on an approval prompt). Set manual or another Claude mode for restricted workers. Ignored by other presets and explicit command overrides.' },
|
|
3638
|
+
permission_mode: { type: 'string', enum: ['acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'manual', 'plan'], description: 'Claude worker permission mode (default: bypassPermissions so delegated tasks cannot park on an approval prompt). Set manual or another Claude mode for restricted workers. The host may cap this (hop config agent-ceiling); a request above the cap is clamped and reported in permission_note. Ignored by other presets and explicit command overrides.' },
|
|
3607
3639
|
initial_task: { type: 'string', description: 'First task to dispatch once ready. Hop appends a unique success-token instruction unless until_reply_regex is supplied.' },
|
|
3608
3640
|
until_reply_regex: { type: 'string', description: 'Optional caller-defined completion contract for initial_task. When omitted, Hop creates and instructs the worker to emit a unique success token. The turn is contract_failed if its final reply does not match.' },
|
|
3609
3641
|
async: { type: 'boolean', description: 'Initial-task behavior: false/default waits for the contracted result and returns initial_task_result; true returns a pending wait_id for explicit fleet orchestration.' },
|
|
@@ -5999,7 +6031,10 @@ class HopMCPServer {
|
|
|
5999
6031
|
// optional contracted first task that blocks unless async is explicit.
|
|
6000
6032
|
async handleSpawnAgent(args = {}) {
|
|
6001
6033
|
const preset = typeof args.agent === 'string' && args.agent ? args.agent : 'claude';
|
|
6002
|
-
const
|
|
6034
|
+
const permissionCeiling = readAgentPermissionCeiling();
|
|
6035
|
+
let permissionNote = null;
|
|
6036
|
+
let appliedPermissionMode = null;
|
|
6037
|
+
const presetCommands = { claude: claudePresetCommand(), codex: codexPresetCommand(permissionCeiling), gemini: 'gemini' };
|
|
6003
6038
|
let command = typeof args.command === 'string' && args.command.trim() ? args.command.trim() : '';
|
|
6004
6039
|
const usesPresetCommand = command.length === 0;
|
|
6005
6040
|
if (args.permission_mode !== undefined && !CLAUDE_PERMISSION_MODES.has(args.permission_mode)) {
|
|
@@ -6028,10 +6063,17 @@ class HopMCPServer {
|
|
|
6028
6063
|
command += ` --model ${defaultModel}`;
|
|
6029
6064
|
}
|
|
6030
6065
|
if (preset === 'claude' && !/(?:^|\s)(?:--permission-mode(?:\s|=|$)|--dangerously-skip-permissions(?:\s|$))/.test(extraArgs)) {
|
|
6031
|
-
const
|
|
6066
|
+
const requestedMode = typeof args.permission_mode === 'string' && args.permission_mode
|
|
6032
6067
|
? args.permission_mode
|
|
6033
|
-
:
|
|
6068
|
+
: DEFAULT_AGENT_PERMISSION_CEILING;
|
|
6069
|
+
const permissionMode = clampPermissionMode(requestedMode, permissionCeiling);
|
|
6070
|
+
if (permissionMode !== requestedMode) {
|
|
6071
|
+
permissionNote = `permission_mode ${requestedMode} clamped to this host's ceiling ${permissionCeiling} (hop config agent-ceiling)`;
|
|
6072
|
+
}
|
|
6073
|
+
appliedPermissionMode = permissionMode;
|
|
6034
6074
|
command += ` --permission-mode ${permissionMode}`;
|
|
6075
|
+
} else if (preset === 'codex' && permissionCeiling !== 'bypassPermissions') {
|
|
6076
|
+
permissionNote = `codex runs sandboxed (--full-auto) under this host's ceiling ${permissionCeiling}`;
|
|
6035
6077
|
}
|
|
6036
6078
|
}
|
|
6037
6079
|
if (!usesPresetCommand && args.permission_mode !== undefined) {
|
|
@@ -6194,6 +6236,8 @@ class HopMCPServer {
|
|
|
6194
6236
|
if (composerReady !== null) {
|
|
6195
6237
|
result.composer_ready = composerReady;
|
|
6196
6238
|
}
|
|
6239
|
+
if (appliedPermissionMode) result.permission_mode = appliedPermissionMode;
|
|
6240
|
+
if (permissionNote) result.permission_note = permissionNote;
|
|
6197
6241
|
if (startupBlocker) {
|
|
6198
6242
|
result.startup_blocker = startupBlocker;
|
|
6199
6243
|
result.readiness_status = startupBlocker.kind;
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const { rotateAuditFile, sweepStaleAuditDirs, AUDIT_FILE_NAME } = require('../lib/audit-rotation');
|
|
8
|
+
|
|
9
|
+
const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hop-audit-rot-'));
|
|
10
|
+
|
|
11
|
+
test('rotateAuditFile renames the live file aside and keeps only the newest N rotations', () => {
|
|
12
|
+
const dir = tmpDir();
|
|
13
|
+
const live = path.join(dir, AUDIT_FILE_NAME);
|
|
14
|
+
const stamps = [];
|
|
15
|
+
for (let i = 0; i < 4; i++) {
|
|
16
|
+
fs.writeFileSync(live, `{"n":${i}}\n`);
|
|
17
|
+
const now = Date.UTC(2026, 0, 1, 0, 0, i);
|
|
18
|
+
const r = rotateAuditFile(live, { keep: 2, now });
|
|
19
|
+
assert.ok(r.rotatedTo, 'live file rotated');
|
|
20
|
+
stamps.push(path.basename(r.rotatedTo));
|
|
21
|
+
// Rotated files get distinct mtimes so the prune order is deterministic.
|
|
22
|
+
fs.utimesSync(r.rotatedTo, now / 1000, now / 1000);
|
|
23
|
+
}
|
|
24
|
+
assert.equal(fs.existsSync(live), false);
|
|
25
|
+
const left = fs.readdirSync(dir).sort();
|
|
26
|
+
assert.equal(left.length, 2, `two rotations kept, got ${left}`);
|
|
27
|
+
assert.deepEqual(left, stamps.slice(-2).sort());
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('rotateAuditFile is a no-op on a missing or empty live file', () => {
|
|
31
|
+
const dir = tmpDir();
|
|
32
|
+
const live = path.join(dir, AUDIT_FILE_NAME);
|
|
33
|
+
assert.equal(rotateAuditFile(live).rotatedTo, null);
|
|
34
|
+
fs.writeFileSync(live, '');
|
|
35
|
+
assert.equal(rotateAuditFile(live).rotatedTo, null);
|
|
36
|
+
assert.ok(fs.existsSync(live));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('sweepStaleAuditDirs removes old non-live session dirs and keeps live or recent ones', () => {
|
|
40
|
+
const root = tmpDir();
|
|
41
|
+
const mk = (name, ageMs, now) => {
|
|
42
|
+
const d = path.join(root, name);
|
|
43
|
+
fs.mkdirSync(d);
|
|
44
|
+
const f = path.join(d, AUDIT_FILE_NAME);
|
|
45
|
+
fs.writeFileSync(f, '{}\n');
|
|
46
|
+
const t = (now - ageMs) / 1000;
|
|
47
|
+
fs.utimesSync(f, t, t);
|
|
48
|
+
fs.utimesSync(d, t, t);
|
|
49
|
+
return d;
|
|
50
|
+
};
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const day = 86400000;
|
|
53
|
+
const stale = mk('old-session', 120 * day, now);
|
|
54
|
+
const liveButOld = mk('live-session', 120 * day, now);
|
|
55
|
+
const recent = mk('recent-session', 2 * day, now);
|
|
56
|
+
const r = sweepStaleAuditDirs([root], {
|
|
57
|
+
retentionMs: 90 * day,
|
|
58
|
+
isLive: (name) => name === 'live-session',
|
|
59
|
+
now
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(r.removed, [stale]);
|
|
62
|
+
assert.equal(fs.existsSync(stale), false);
|
|
63
|
+
assert.ok(fs.existsSync(liveButOld));
|
|
64
|
+
assert.ok(fs.existsSync(recent));
|
|
65
|
+
assert.equal(r.kept, 2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('sweepStaleAuditDirs does nothing when retention is disabled or the root is missing', () => {
|
|
69
|
+
const root = tmpDir();
|
|
70
|
+
fs.mkdirSync(path.join(root, 'x'));
|
|
71
|
+
assert.deepEqual(sweepStaleAuditDirs([root], { retentionMs: 0 }).removed, []);
|
|
72
|
+
assert.deepEqual(sweepStaleAuditDirs([path.join(root, 'nope')], { retentionMs: 1 }).removed, []);
|
|
73
|
+
assert.ok(fs.existsSync(path.join(root, 'x')));
|
|
74
|
+
});
|