machine-bridge-mcp 1.2.11 → 2.0.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/CHANGELOG.md +14 -1
- package/README.md +10 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/AUDIT.md +25 -3
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +2 -2
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +111 -0
- package/docs/LOGGING.md +4 -4
- package/docs/MULTI_ACCOUNT.md +5 -5
- package/docs/OPERATIONS.md +30 -5
- package/docs/OVERVIEW.md +12 -8
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/RELEASING.md +3 -3
- package/docs/TESTING.md +5 -2
- package/docs/THREAT_MODEL.md +7 -6
- package/docs/UPGRADING.md +10 -6
- package/package.json +4 -2
- package/scripts/check-plan.mjs +2 -0
- package/scripts/local-release-acceptance.mjs +2 -2
- package/scripts/release-acceptance.mjs +7 -4
- package/src/local/account-admin.mjs +28 -3
- package/src/local/cli-approval.mjs +117 -0
- package/src/local/cli-options.mjs +8 -2
- package/src/local/cli.mjs +13 -2
- package/src/local/device-identity.mjs +108 -0
- package/src/local/operation-authorization.mjs +366 -0
- package/src/local/operation-risk.mjs +221 -0
- package/src/local/operation-state-lock.mjs +92 -0
- package/src/local/relay-connection.mjs +12 -5
- package/src/local/runtime-relay.mjs +13 -5
- package/src/local/runtime.mjs +13 -7
- package/src/local/state.mjs +9 -3
- package/src/local/tool-executor.mjs +4 -2
- package/src/local/worker-deployment.mjs +4 -3
- package/src/local/worker-secret-file.mjs +2 -1
- package/src/shared/admin-auth.d.mts +10 -0
- package/src/shared/admin-auth.mjs +36 -0
- package/src/shared/daemon-auth.d.mts +20 -0
- package/src/shared/daemon-auth.mjs +55 -0
- package/src/worker/account-admin.ts +73 -4
- package/src/worker/daemon-auth.ts +205 -0
- package/src/worker/daemon-sockets.ts +15 -2
- package/src/worker/index.ts +59 -10
- package/src/worker/nonce-store.ts +79 -0
- package/src/worker/oauth-controller.ts +11 -6
- package/src/worker/oauth-refresh-families.ts +172 -0
- package/src/worker/oauth-state.ts +48 -3
- package/src/worker/oauth-tokens.ts +49 -40
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign, webcrypto } from "node:crypto";
|
|
2
|
+
import { DAEMON_AUTH_SCHEME, DAEMON_PREFLIGHT_SCHEME, daemonAuthTranscript, daemonPreflightTranscript } from "../shared/daemon-auth.mjs";
|
|
3
|
+
|
|
4
|
+
const DEVICE_KEY_TYPE = "EC";
|
|
5
|
+
const DEVICE_CURVE = "P-256";
|
|
6
|
+
|
|
7
|
+
export function createDeviceIdentity() {
|
|
8
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
9
|
+
const privateJwk = privateKey.export({ format: "jwk" });
|
|
10
|
+
const publicJwk = publicKey.export({ format: "jwk" });
|
|
11
|
+
validatePrivateDeviceJwk(privateJwk);
|
|
12
|
+
validatePublicDeviceJwk(publicJwk);
|
|
13
|
+
return {
|
|
14
|
+
scheme: DAEMON_AUTH_SCHEME,
|
|
15
|
+
privateJwk,
|
|
16
|
+
publicJwk,
|
|
17
|
+
keyId: deviceKeyId(publicJwk),
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function publicDeviceJwkJson(identity) {
|
|
23
|
+
const publicJwk = identity?.publicJwk;
|
|
24
|
+
validatePublicDeviceJwk(publicJwk);
|
|
25
|
+
return JSON.stringify(publicJwk);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export function createDaemonPreflightHeaders(identity, workerOrigin, server, version, now = Date.now()) {
|
|
30
|
+
validatePrivateDeviceJwk(identity?.privateJwk);
|
|
31
|
+
const issuedAt = Math.floor(Number(now) / 1000);
|
|
32
|
+
if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0) throw new Error("device preflight timestamp is invalid");
|
|
33
|
+
const nonce = randomBytes(24).toString("base64url");
|
|
34
|
+
const transcript = daemonPreflightTranscript({ workerOrigin, server, version, nonce, issuedAt });
|
|
35
|
+
const key = createPrivateKey({ key: identity.privateJwk, format: "jwk" });
|
|
36
|
+
const signature = sign("sha256", Buffer.from(transcript, "utf8"), { key, dsaEncoding: "ieee-p1363" }).toString("base64url");
|
|
37
|
+
return {
|
|
38
|
+
"X-Bridge-Device-Scheme": DAEMON_PREFLIGHT_SCHEME,
|
|
39
|
+
"X-Bridge-Device-Key": String(identity.keyId || deviceKeyId(identity.publicJwk)),
|
|
40
|
+
"X-Bridge-Device-Nonce": nonce,
|
|
41
|
+
"X-Bridge-Device-Time": String(issuedAt),
|
|
42
|
+
"X-Bridge-Device-Signature": signature,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function createDaemonAuthentication(identity, welcome, instanceId) {
|
|
47
|
+
validatePrivateDeviceJwk(identity?.privateJwk);
|
|
48
|
+
const auth = welcome?.authentication;
|
|
49
|
+
if (!auth || auth.scheme !== DAEMON_AUTH_SCHEME) throw new Error("Worker did not request supported device authentication");
|
|
50
|
+
const workerOrigin = new URL(String(welcome.worker_origin || "")).origin;
|
|
51
|
+
const transcript = daemonAuthTranscript({
|
|
52
|
+
challenge: auth.challenge,
|
|
53
|
+
workerOrigin,
|
|
54
|
+
server: welcome.server,
|
|
55
|
+
version: welcome.version,
|
|
56
|
+
instanceId,
|
|
57
|
+
issuedAt: auth.issued_at,
|
|
58
|
+
});
|
|
59
|
+
const privateKey = await webcrypto.subtle.importKey(
|
|
60
|
+
"jwk",
|
|
61
|
+
identity.privateJwk,
|
|
62
|
+
{ name: "ECDSA", namedCurve: DEVICE_CURVE },
|
|
63
|
+
false,
|
|
64
|
+
["sign"],
|
|
65
|
+
);
|
|
66
|
+
const signature = await webcrypto.subtle.sign(
|
|
67
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
68
|
+
privateKey,
|
|
69
|
+
new TextEncoder().encode(transcript),
|
|
70
|
+
);
|
|
71
|
+
return {
|
|
72
|
+
scheme: DAEMON_AUTH_SCHEME,
|
|
73
|
+
challenge: String(auth.challenge),
|
|
74
|
+
issued_at: Number(auth.issued_at),
|
|
75
|
+
key_id: String(identity.keyId || deviceKeyId(identity.publicJwk)),
|
|
76
|
+
signature: Buffer.from(signature).toString("base64url"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function deviceKeyId(publicJwk) {
|
|
81
|
+
validatePublicDeviceJwk(publicJwk);
|
|
82
|
+
const canonical = JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y });
|
|
83
|
+
return `device_${createHash("sha256").update(canonical).digest("base64url").slice(0, 32)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateDeviceIdentity(identity) {
|
|
87
|
+
if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error("device identity is missing");
|
|
88
|
+
if (identity.scheme !== DAEMON_AUTH_SCHEME) throw new Error("device identity scheme is unsupported");
|
|
89
|
+
validatePrivateDeviceJwk(identity.privateJwk);
|
|
90
|
+
validatePublicDeviceJwk(identity.publicJwk);
|
|
91
|
+
const expectedKeyId = deviceKeyId(identity.publicJwk);
|
|
92
|
+
if (identity.keyId !== expectedKeyId) throw new Error("device identity key id does not match its public key");
|
|
93
|
+
return identity;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validatePrivateDeviceJwk(jwk) {
|
|
97
|
+
validatePublicDeviceJwk(jwk);
|
|
98
|
+
if (typeof jwk.d !== "string" || !/^[A-Za-z0-9_-]{40,48}$/.test(jwk.d)) throw new Error("device private key is invalid");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validatePublicDeviceJwk(jwk) {
|
|
102
|
+
if (!jwk || typeof jwk !== "object" || Array.isArray(jwk)) throw new Error("device public key is invalid");
|
|
103
|
+
if (jwk.kty !== DEVICE_KEY_TYPE || jwk.crv !== DEVICE_CURVE) throw new Error("device public key must use P-256");
|
|
104
|
+
if (typeof jwk.x !== "string" || typeof jwk.y !== "string") throw new Error("device public key coordinates are invalid");
|
|
105
|
+
if (!/^[A-Za-z0-9_-]{42,44}$/.test(jwk.x) || !/^[A-Za-z0-9_-]{42,44}$/.test(jwk.y)) {
|
|
106
|
+
throw new Error("device public key coordinates are invalid");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { BridgeError } from "./errors.mjs";
|
|
5
|
+
import { OPERATION_APPROVAL_SCOPES, classifyOperation } from "./operation-risk.mjs";
|
|
6
|
+
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
7
|
+
import { withOperationStateLock } from "./operation-state-lock.mjs";
|
|
8
|
+
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const MAX_STATE_BYTES = 256 * 1024;
|
|
12
|
+
const MAX_PENDING = 100;
|
|
13
|
+
const MAX_LEASES = 256;
|
|
14
|
+
const PENDING_TTL_SECONDS = 10 * 60;
|
|
15
|
+
const DEFAULT_LEASE_SECONDS = 60 * 60;
|
|
16
|
+
const MAX_LEASE_SECONDS = 12 * 60 * 60;
|
|
17
|
+
const FULL_MAX_LEASE_SECONDS = 8 * 60 * 60;
|
|
18
|
+
|
|
19
|
+
export { OPERATION_APPROVAL_SCOPES, classifyOperation };
|
|
20
|
+
|
|
21
|
+
const SCOPE_SET = new Set(OPERATION_APPROVAL_SCOPES);
|
|
22
|
+
|
|
23
|
+
export class OperationAuthorizer {
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.workspace = path.resolve(String(options.workspace || process.cwd()));
|
|
26
|
+
this.root = options.root ? path.resolve(String(options.root)) : "";
|
|
27
|
+
this.resolveExistingPath = options.resolveExistingPath;
|
|
28
|
+
this.resolveWritePath = options.resolveWritePath;
|
|
29
|
+
this.now = typeof options.now === "function" ? options.now : Date.now;
|
|
30
|
+
this.queue = Promise.resolve();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async authorize(operation) {
|
|
34
|
+
if (operation?.context?.origin !== "relay") return { allowed: true, source: "local" };
|
|
35
|
+
const authorization = operation.request?.authorization;
|
|
36
|
+
const accountId = String(authorization?.account_id || "");
|
|
37
|
+
const clientId = String(authorization?.client_id || "");
|
|
38
|
+
const role = String(authorization?.role || "").trim().toLowerCase();
|
|
39
|
+
if (!/^acct_[A-Za-z0-9_-]{20,96}$/.test(accountId) || !/^mcp_client_[A-Za-z0-9_-]{43}$/.test(clientId)) {
|
|
40
|
+
throw new BridgeError("authorization_denied", "relay operation is missing authenticated client identity");
|
|
41
|
+
}
|
|
42
|
+
if (role === "owner") {
|
|
43
|
+
return {
|
|
44
|
+
allowed: true,
|
|
45
|
+
source: "authenticated-owner",
|
|
46
|
+
accountId,
|
|
47
|
+
clientId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const requirement = await classifyOperation(operation.tool, operation.args, {
|
|
51
|
+
workspace: this.workspace,
|
|
52
|
+
resolveExistingPath: this.resolveExistingPath,
|
|
53
|
+
resolveWritePath: this.resolveWritePath,
|
|
54
|
+
});
|
|
55
|
+
if (!requirement) return { allowed: true, source: "automatic" };
|
|
56
|
+
if (!this.root) throw new BridgeError("authorization_denied", "local operation approval storage is unavailable");
|
|
57
|
+
|
|
58
|
+
const requiredScopes = normalizeScopes(requirement.scopes || [requirement.scope]);
|
|
59
|
+
const matches = matchingLeases(this.root, { accountId, clientId, scopes: requiredScopes }, this.now());
|
|
60
|
+
const missingScopes = requiredScopes.filter((scope) => !matches.byScope.has(scope));
|
|
61
|
+
if (!missingScopes.length) {
|
|
62
|
+
const leaseIds = [...new Set(matches.leases.map((lease) => lease.id))];
|
|
63
|
+
return {
|
|
64
|
+
allowed: true,
|
|
65
|
+
source: "lease",
|
|
66
|
+
leaseId: leaseIds[0],
|
|
67
|
+
leaseIds,
|
|
68
|
+
scope: requiredScopes[0],
|
|
69
|
+
scopes: requiredScopes,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pending = await this.enqueue(() => recordPendingApproval(this.root, {
|
|
74
|
+
accountId,
|
|
75
|
+
clientId,
|
|
76
|
+
tool: operation.tool,
|
|
77
|
+
scopes: missingScopes,
|
|
78
|
+
category: requirement.category,
|
|
79
|
+
targetHash: requirement.targetHash,
|
|
80
|
+
}, this.now()));
|
|
81
|
+
const scopeText = missingScopes.join(", ");
|
|
82
|
+
throw new BridgeError(
|
|
83
|
+
"authorization_denied",
|
|
84
|
+
`local approval required for ${requirement.category} (${scopeText}); approve the missing scopes with: machine-mcp approval approve ${pending.id} --duration 1h; or open an explicit temporary full window with: machine-mcp approval approve ${pending.id} --full`,
|
|
85
|
+
{
|
|
86
|
+
retryable: true,
|
|
87
|
+
details: {
|
|
88
|
+
reason: "local_approval_required",
|
|
89
|
+
approval_id: pending.id,
|
|
90
|
+
scope: missingScopes[0],
|
|
91
|
+
scopes: missingScopes,
|
|
92
|
+
required_scopes: requiredScopes,
|
|
93
|
+
expires_at: pending.expires_at,
|
|
94
|
+
approve_command: `machine-mcp approval approve ${pending.id} --duration 1h`,
|
|
95
|
+
full_command: `machine-mcp approval approve ${pending.id} --full`,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
enqueue(callback) {
|
|
102
|
+
const result = this.queue.then(callback, callback);
|
|
103
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function listOperationApprovals(root, now = Date.now()) {
|
|
109
|
+
return {
|
|
110
|
+
leases: currentLeases(root, now),
|
|
111
|
+
pending: currentPending(root, now),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function approvePendingOperation(root, pendingId, duration = "1h", now = Date.now(), scopeOverride = "") {
|
|
116
|
+
if (scopeOverride && scopeOverride !== "full") throw new Error("pending approval scopes may only be elevated to full");
|
|
117
|
+
return withOperationStateLock(root, async () => {
|
|
118
|
+
const pendingState = readPendingState(root);
|
|
119
|
+
const leaseState = readLeaseState(root);
|
|
120
|
+
const current = epochSeconds(now);
|
|
121
|
+
const pending = pendingState.pending.find((entry) => entry.id === String(pendingId || "") && entry.expires_at > current);
|
|
122
|
+
if (!pending) throw new Error("pending approval was not found or has expired");
|
|
123
|
+
const scopes = scopeOverride ? [scopeOverride] : pending.scopes;
|
|
124
|
+
let lease = leaseState.leases.find((entry) => entry.source_approval_id === pending.id && entry.expires_at > current);
|
|
125
|
+
if (!lease) {
|
|
126
|
+
lease = appendLease(leaseState, {
|
|
127
|
+
accountId: pending.account_id,
|
|
128
|
+
clientId: pending.client_id,
|
|
129
|
+
scopes,
|
|
130
|
+
duration,
|
|
131
|
+
sourceApprovalId: pending.id,
|
|
132
|
+
}, now);
|
|
133
|
+
writeJson(leasePath(root), leaseState);
|
|
134
|
+
}
|
|
135
|
+
pendingState.pending = pendingState.pending.filter((entry) => entry.id !== pending.id && entry.expires_at > current);
|
|
136
|
+
writeJson(pendingPath(root), pendingState);
|
|
137
|
+
return lease;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function grantOperationLease(root, { accountId = "*", clientId, scope, duration = "1h" }, now = Date.now()) {
|
|
142
|
+
if (!/^mcp_client_[A-Za-z0-9_-]{43}$/.test(String(clientId || "")) && clientId !== "*") {
|
|
143
|
+
throw new Error("approval grant requires a valid OAuth client id or *");
|
|
144
|
+
}
|
|
145
|
+
if (accountId !== "*" && !/^acct_[A-Za-z0-9_-]{20,96}$/.test(String(accountId || ""))) {
|
|
146
|
+
throw new Error("approval account id is invalid");
|
|
147
|
+
}
|
|
148
|
+
return withOperationStateLock(root, async () => {
|
|
149
|
+
const state = readLeaseState(root);
|
|
150
|
+
const lease = appendLease(state, { accountId, clientId, scopes: [scope], duration }, now);
|
|
151
|
+
writeJson(leasePath(root), state);
|
|
152
|
+
return lease;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function revokeOperationLease(root, leaseId, now = Date.now()) {
|
|
157
|
+
return withOperationStateLock(root, async () => {
|
|
158
|
+
const state = readLeaseState(root);
|
|
159
|
+
const current = epochSeconds(now);
|
|
160
|
+
const requested = String(leaseId || "");
|
|
161
|
+
const removed = state.leases.some((lease) => lease.id === requested && lease.expires_at > current);
|
|
162
|
+
const before = state.leases.length;
|
|
163
|
+
state.leases = state.leases.filter((lease) => lease.expires_at > current && lease.id !== requested);
|
|
164
|
+
if (state.leases.length !== before) writeJson(leasePath(root), state);
|
|
165
|
+
return removed;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function clearOperationLeases(root) {
|
|
170
|
+
return withOperationStateLock(root, async () => {
|
|
171
|
+
writeJson(leasePath(root), emptyLeaseState());
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function parseApprovalDuration(value, scopes = "") {
|
|
176
|
+
const text = String(value || "1h").trim().toLowerCase();
|
|
177
|
+
const match = /^(\d+)(m|h)$/.exec(text);
|
|
178
|
+
if (!match) throw new Error("approval duration must use minutes or hours, for example 30m or 2h");
|
|
179
|
+
const seconds = Number(match[1]) * (match[2] === "h" ? 3600 : 60);
|
|
180
|
+
const normalizedScopes = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
|
|
181
|
+
const maximum = normalizedScopes.includes("full") ? FULL_MAX_LEASE_SECONDS : MAX_LEASE_SECONDS;
|
|
182
|
+
if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > maximum) {
|
|
183
|
+
throw new Error(`approval duration must be between 1m and ${maximum / 3600}h`);
|
|
184
|
+
}
|
|
185
|
+
return seconds;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function recordPendingApproval(root, input, now) {
|
|
189
|
+
return withOperationStateLock(root, async () => {
|
|
190
|
+
const state = readPendingState(root);
|
|
191
|
+
const current = epochSeconds(now);
|
|
192
|
+
const scopes = normalizeScopes(input.scopes);
|
|
193
|
+
state.pending = state.pending.filter((entry) => entry.expires_at > current);
|
|
194
|
+
const existing = state.pending.find((entry) => (
|
|
195
|
+
entry.account_id === input.accountId
|
|
196
|
+
&& entry.client_id === input.clientId
|
|
197
|
+
&& sameScopes(entry.scopes, scopes)
|
|
198
|
+
&& entry.target_hash === input.targetHash
|
|
199
|
+
));
|
|
200
|
+
if (existing) return existing;
|
|
201
|
+
const pending = {
|
|
202
|
+
id: `approval_${randomBytes(18).toString("base64url")}`,
|
|
203
|
+
account_id: input.accountId,
|
|
204
|
+
client_id: input.clientId,
|
|
205
|
+
tool: String(input.tool).slice(0, 128),
|
|
206
|
+
scopes,
|
|
207
|
+
category: String(input.category).slice(0, 160),
|
|
208
|
+
target_hash: input.targetHash,
|
|
209
|
+
created_at: current,
|
|
210
|
+
expires_at: current + PENDING_TTL_SECONDS,
|
|
211
|
+
};
|
|
212
|
+
state.pending.push(pending);
|
|
213
|
+
state.pending = state.pending.slice(-MAX_PENDING);
|
|
214
|
+
writeJson(pendingPath(root), state);
|
|
215
|
+
return pending;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function appendLease(state, input, now) {
|
|
220
|
+
const scopes = normalizeScopes(input.scopes);
|
|
221
|
+
if (!scopes.length) throw new Error("capability lease requires at least one known scope");
|
|
222
|
+
if (scopes.includes("full") && scopes.length !== 1) throw new Error("full capability lease cannot be combined with narrower scopes");
|
|
223
|
+
const seconds = parseApprovalDuration(input.duration || `${DEFAULT_LEASE_SECONDS / 3600}h`, scopes);
|
|
224
|
+
const current = epochSeconds(now);
|
|
225
|
+
state.leases = state.leases.filter((lease) => lease.expires_at > current);
|
|
226
|
+
if (state.leases.length >= MAX_LEASES) throw new Error(`operation capability leases exceed the maximum of ${MAX_LEASES}`);
|
|
227
|
+
const lease = {
|
|
228
|
+
id: `lease_${randomBytes(18).toString("base64url")}`,
|
|
229
|
+
account_id: String(input.accountId || "*"),
|
|
230
|
+
client_id: String(input.clientId || ""),
|
|
231
|
+
scopes,
|
|
232
|
+
created_at: current,
|
|
233
|
+
expires_at: current + seconds,
|
|
234
|
+
...(input.sourceApprovalId ? { source_approval_id: String(input.sourceApprovalId) } : {}),
|
|
235
|
+
};
|
|
236
|
+
state.leases.push(lease);
|
|
237
|
+
return lease;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function matchingLeases(root, input, now) {
|
|
241
|
+
const current = epochSeconds(now);
|
|
242
|
+
const candidates = currentLeases(root, now).filter((lease) => (
|
|
243
|
+
(lease.account_id === "*" || lease.account_id === input.accountId)
|
|
244
|
+
&& (lease.client_id === "*" || lease.client_id === input.clientId)
|
|
245
|
+
&& lease.expires_at > current
|
|
246
|
+
));
|
|
247
|
+
const byScope = new Map();
|
|
248
|
+
for (const scope of input.scopes) {
|
|
249
|
+
const lease = candidates.find((entry) => entry.scopes.includes("full") || entry.scopes.includes(scope));
|
|
250
|
+
if (lease) byScope.set(scope, lease);
|
|
251
|
+
}
|
|
252
|
+
return { byScope, leases: [...byScope.values()] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function currentLeases(root, now) {
|
|
256
|
+
const current = epochSeconds(now);
|
|
257
|
+
return readLeaseState(root).leases.filter((lease) => lease.expires_at > current);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function currentPending(root, now) {
|
|
261
|
+
const current = epochSeconds(now);
|
|
262
|
+
return readPendingState(root).pending.filter((entry) => entry.expires_at > current);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readLeaseState(root) {
|
|
266
|
+
return readState(leasePath(root), emptyLeaseState, "leases", validLease);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readPendingState(root) {
|
|
270
|
+
return readState(pendingPath(root), emptyPendingState, "pending", validPending);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readState(file, empty, arrayKey, validEntry) {
|
|
274
|
+
if (!existsSync(file)) return empty();
|
|
275
|
+
const raw = readBoundedRegularFileSync(file, MAX_STATE_BYTES, "operation approval state");
|
|
276
|
+
let parsed;
|
|
277
|
+
try { parsed = JSON.parse(raw.toString("utf8")); } catch (error) {
|
|
278
|
+
throw new Error("operation approval state is not valid JSON", { cause: error });
|
|
279
|
+
}
|
|
280
|
+
if (
|
|
281
|
+
!parsed
|
|
282
|
+
|| typeof parsed !== "object"
|
|
283
|
+
|| Array.isArray(parsed)
|
|
284
|
+
|| parsed.schemaVersion !== SCHEMA_VERSION
|
|
285
|
+
|| !Array.isArray(parsed[arrayKey])
|
|
286
|
+
|| !parsed[arrayKey].every(validEntry)
|
|
287
|
+
) {
|
|
288
|
+
throw new Error("operation approval state schema is invalid");
|
|
289
|
+
}
|
|
290
|
+
return parsed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function validLease(value) {
|
|
294
|
+
if (!plainRecord(value)) return false;
|
|
295
|
+
if (!/^lease_[A-Za-z0-9_-]{24}$/.test(value.id)) return false;
|
|
296
|
+
if (!validAccountBinding(value.account_id) || !validClientBinding(value.client_id)) return false;
|
|
297
|
+
if (!validScopes(value.scopes) || !validTimestampRange(value.created_at, value.expires_at)) return false;
|
|
298
|
+
const maximum = value.scopes.includes("full") ? FULL_MAX_LEASE_SECONDS : MAX_LEASE_SECONDS;
|
|
299
|
+
if (value.expires_at - value.created_at > maximum) return false;
|
|
300
|
+
return value.source_approval_id === undefined || /^approval_[A-Za-z0-9_-]{24}$/.test(value.source_approval_id);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validPending(value) {
|
|
304
|
+
if (!plainRecord(value)) return false;
|
|
305
|
+
return /^approval_[A-Za-z0-9_-]{24}$/.test(value.id)
|
|
306
|
+
&& validAccountBinding(value.account_id, false)
|
|
307
|
+
&& validClientBinding(value.client_id, false)
|
|
308
|
+
&& /^[a-z][a-z0-9_]{0,127}$/.test(value.tool)
|
|
309
|
+
&& validScopes(value.scopes, false)
|
|
310
|
+
&& typeof value.category === "string"
|
|
311
|
+
&& value.category.length > 0
|
|
312
|
+
&& value.category.length <= 160
|
|
313
|
+
&& !/[\u0000-\u001f\u007f]/.test(value.category)
|
|
314
|
+
&& /^[a-f0-9]{64}$/.test(value.target_hash)
|
|
315
|
+
&& validTimestampRange(value.created_at, value.expires_at)
|
|
316
|
+
&& value.expires_at - value.created_at === PENDING_TTL_SECONDS;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function validScopes(value, allowFull = true) {
|
|
320
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > OPERATION_APPROVAL_SCOPES.length) return false;
|
|
321
|
+
if (value.some((scope) => !SCOPE_SET.has(scope))) return false;
|
|
322
|
+
if (!sameScopes(value, normalizeScopes(value))) return false;
|
|
323
|
+
if (!allowFull && value.includes("full")) return false;
|
|
324
|
+
return !value.includes("full") || value.length === 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeScopes(scopes) {
|
|
328
|
+
const requested = new Set((Array.isArray(scopes) ? scopes : []).map((scope) => String(scope || "")));
|
|
329
|
+
return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function sameScopes(left, right) {
|
|
333
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((scope, index) => scope === right[index]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validAccountBinding(value, wildcard = true) {
|
|
337
|
+
return (wildcard && value === "*") || /^acct_[A-Za-z0-9_-]{20,96}$/.test(String(value || ""));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function validClientBinding(value, wildcard = true) {
|
|
341
|
+
return (wildcard && value === "*") || /^mcp_client_[A-Za-z0-9_-]{43}$/.test(String(value || ""));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function validTimestampRange(createdAt, expiresAt) {
|
|
345
|
+
return Number.isSafeInteger(createdAt)
|
|
346
|
+
&& Number.isSafeInteger(expiresAt)
|
|
347
|
+
&& createdAt > 0
|
|
348
|
+
&& expiresAt > createdAt;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function plainRecord(value) {
|
|
352
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function writeJson(file, value) {
|
|
356
|
+
ensureOwnerOnlyDirectorySync(path.dirname(file));
|
|
357
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
358
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("operation approval state exceeds its size limit");
|
|
359
|
+
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function leasePath(root) { return path.join(path.resolve(root), "operation-leases.json"); }
|
|
363
|
+
function pendingPath(root) { return path.join(path.resolve(root), "operation-pending.json"); }
|
|
364
|
+
function emptyLeaseState() { return { schemaVersion: SCHEMA_VERSION, leases: [] }; }
|
|
365
|
+
function emptyPendingState() { return { schemaVersion: SCHEMA_VERSION, pending: [] }; }
|
|
366
|
+
function epochSeconds(value) { return Math.floor(Number(value) / 1000); }
|