@suveren/gateway 0.16.1 → 0.17.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/dist/control-plane/index.mjs +272 -17
- package/dist/mcp-server/http.mjs +108 -7
- package/dist/ui/assets/index-BKgX8_Zh.js +111 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-tdGbzBko.js +0 -110
|
@@ -40,6 +40,8 @@ var Vault = class {
|
|
|
40
40
|
* disk, gone the moment the process restarts or the vault locks.
|
|
41
41
|
*/
|
|
42
42
|
spSessionExpiresAt = null;
|
|
43
|
+
/** Who the current AS session belongs to (from the login response). Never persisted. */
|
|
44
|
+
sessionUser = null;
|
|
43
45
|
/** Set by lockExpired(); cleared by any fresh unseal (a new login). */
|
|
44
46
|
lockedReason = null;
|
|
45
47
|
constructor(dataDir) {
|
|
@@ -97,6 +99,7 @@ var Vault = class {
|
|
|
97
99
|
this.spSessionCookie = null;
|
|
98
100
|
this.unsealedBy = null;
|
|
99
101
|
this.spSessionExpiresAt = null;
|
|
102
|
+
this.sessionUser = null;
|
|
100
103
|
this.lockedReason = null;
|
|
101
104
|
}
|
|
102
105
|
/**
|
|
@@ -149,6 +152,13 @@ var Vault = class {
|
|
|
149
152
|
getSessionExpiresAt() {
|
|
150
153
|
return this.spSessionExpiresAt;
|
|
151
154
|
}
|
|
155
|
+
/** The signed-in person, set at login from the AS session response; cleared with the session. */
|
|
156
|
+
setSessionUser(user) {
|
|
157
|
+
this.sessionUser = user;
|
|
158
|
+
}
|
|
159
|
+
getSessionUser() {
|
|
160
|
+
return this.sessionUser;
|
|
161
|
+
}
|
|
152
162
|
/**
|
|
153
163
|
* Check if the vault was created by a different API key.
|
|
154
164
|
* Tries to decrypt the first credential — if it fails, keys don't match.
|
|
@@ -414,6 +424,11 @@ async function runCommittedProposals() {
|
|
|
414
424
|
headers: internalHeaders()
|
|
415
425
|
});
|
|
416
426
|
}
|
|
427
|
+
async function getToolDisplay() {
|
|
428
|
+
const res = await fetch(`${MCP_BASE}/internal/tool-display`, { headers: internalHeaders() });
|
|
429
|
+
if (!res.ok) throw new Error(`MCP tool-display failed: ${res.status}`);
|
|
430
|
+
return res.json();
|
|
431
|
+
}
|
|
417
432
|
async function getIntegrations() {
|
|
418
433
|
const res = await fetch(`${MCP_BASE}/internal/integrations`, {
|
|
419
434
|
headers: internalHeaders()
|
|
@@ -1726,6 +1741,10 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
|
|
|
1726
1741
|
}
|
|
1727
1742
|
const sessionExpiresAt = typeof data.sessionExpiresAt === "number" ? data.sessionExpiresAt : null;
|
|
1728
1743
|
vault2.setSessionExpiresAt(sessionExpiresAt);
|
|
1744
|
+
const sessionUser = data.user;
|
|
1745
|
+
vault2.setSessionUser(
|
|
1746
|
+
typeof sessionUser?.id === "string" && typeof sessionUser?.did === "string" ? { id: sessionUser.id, did: sessionUser.did } : null
|
|
1747
|
+
);
|
|
1729
1748
|
onSessionEstablished?.();
|
|
1730
1749
|
if (sessionCookie) {
|
|
1731
1750
|
try {
|
|
@@ -3087,7 +3106,26 @@ function notify(title, message, platform = process.platform, url) {
|
|
|
3087
3106
|
|
|
3088
3107
|
// src/routes/encrypt-intent.ts
|
|
3089
3108
|
import { Router as Router7 } from "express";
|
|
3109
|
+
|
|
3110
|
+
// src/lib/intent-encryption.ts
|
|
3090
3111
|
import { computeIntentDisclosureHash } from "@hap/core";
|
|
3112
|
+
async function encryptIntentForRecipients(intent, recipients) {
|
|
3113
|
+
const parsed = recipients.map(({ userId, publicKey }) => {
|
|
3114
|
+
const bytes = new Uint8Array(Buffer.from(publicKey, "base64"));
|
|
3115
|
+
if (bytes.length !== 32) throw new Error(`publicKey for userId "${userId}" must be 32 bytes (X25519); got ${bytes.length}`);
|
|
3116
|
+
return { userId, publicKey: bytes };
|
|
3117
|
+
});
|
|
3118
|
+
const encrypted = await encryptForRecipients(intent, parsed);
|
|
3119
|
+
const encryptedKeys = {};
|
|
3120
|
+
for (const [userId, wrap] of Object.entries(encrypted.encryptedKeys)) {
|
|
3121
|
+
encryptedKeys[userId] = { ct: Buffer.from(wrap.ct).toString("base64"), enc: Buffer.from(wrap.enc).toString("base64") };
|
|
3122
|
+
}
|
|
3123
|
+
const intentCiphertext = Buffer.from(encrypted.intentCiphertext).toString("base64");
|
|
3124
|
+
const approversFrozen = parsed.map((r) => r.userId);
|
|
3125
|
+
return { intentCiphertext, encryptedKeys, approversFrozen, intentDisclosureHash: computeIntentDisclosureHash(intentCiphertext, approversFrozen) };
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// src/routes/encrypt-intent.ts
|
|
3091
3129
|
function createEncryptIntentRouter() {
|
|
3092
3130
|
const router = Router7();
|
|
3093
3131
|
router.post("/", async (req, res) => {
|
|
@@ -3125,23 +3163,10 @@ function createEncryptIntentRouter() {
|
|
|
3125
3163
|
parsed.push({ userId, publicKey: pubBytes });
|
|
3126
3164
|
}
|
|
3127
3165
|
try {
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
ct: Buffer.from(wrap.ct).toString("base64"),
|
|
3133
|
-
enc: Buffer.from(wrap.enc).toString("base64")
|
|
3134
|
-
};
|
|
3135
|
-
}
|
|
3136
|
-
const intentCiphertextB64 = Buffer.from(encrypted.intentCiphertext).toString("base64");
|
|
3137
|
-
const approversFrozen = parsed.map((r) => r.userId);
|
|
3138
|
-
const intentDisclosureHash = computeIntentDisclosureHash(intentCiphertextB64, approversFrozen);
|
|
3139
|
-
res.json({
|
|
3140
|
-
intentCiphertext: intentCiphertextB64,
|
|
3141
|
-
encryptedKeys,
|
|
3142
|
-
approversFrozen,
|
|
3143
|
-
intentDisclosureHash
|
|
3144
|
-
});
|
|
3166
|
+
res.json(await encryptIntentForRecipients(
|
|
3167
|
+
intent,
|
|
3168
|
+
parsed.map((r) => ({ userId: r.userId, publicKey: Buffer.from(r.publicKey).toString("base64") }))
|
|
3169
|
+
));
|
|
3145
3170
|
} catch (err) {
|
|
3146
3171
|
console.error("[Control Plane] encrypt-intent error:", err);
|
|
3147
3172
|
res.status(500).json({ error: err instanceof Error ? err.message : "Encryption failed" });
|
|
@@ -3757,6 +3782,201 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
|
|
|
3757
3782
|
return router;
|
|
3758
3783
|
}
|
|
3759
3784
|
|
|
3785
|
+
// src/routes/internal-mandate.ts
|
|
3786
|
+
import { Router as Router15 } from "express";
|
|
3787
|
+
import { timingSafeEqual as timingSafeEqual3, randomUUID } from "crypto";
|
|
3788
|
+
|
|
3789
|
+
// src/lib/mandate-ceremony.ts
|
|
3790
|
+
import {
|
|
3791
|
+
allowedCommitmentModes,
|
|
3792
|
+
computeBoundsHash,
|
|
3793
|
+
computeContextHash,
|
|
3794
|
+
computeIntentHash,
|
|
3795
|
+
isCommitmentModeAllowed,
|
|
3796
|
+
validateBoundsParams,
|
|
3797
|
+
validateContextParams
|
|
3798
|
+
} from "@hap/core";
|
|
3799
|
+
var MandateRefused = class extends Error {
|
|
3800
|
+
};
|
|
3801
|
+
var INTENT_MAX = 2e3;
|
|
3802
|
+
var shortOf = (id) => id.replace(/@.*$/, "").split("/").pop() ?? id;
|
|
3803
|
+
var versionOf = (id) => id.split("@")[1] ?? "";
|
|
3804
|
+
function refuse(msg) {
|
|
3805
|
+
throw new MandateRefused(msg);
|
|
3806
|
+
}
|
|
3807
|
+
async function resolveProfile(as, requested) {
|
|
3808
|
+
let id = requested.trim();
|
|
3809
|
+
if (!id.includes("@")) {
|
|
3810
|
+
const list = await as("GET", "/api/profiles");
|
|
3811
|
+
const matches = (list.body?.profiles ?? []).filter((p) => shortOf(p.id) === id);
|
|
3812
|
+
if (matches.length === 0) refuse(`Unknown profile "${requested}".`);
|
|
3813
|
+
matches.sort((a, b) => versionOf(b.id).localeCompare(versionOf(a.id), void 0, { numeric: true }));
|
|
3814
|
+
id = matches[0].id;
|
|
3815
|
+
}
|
|
3816
|
+
const res = await as("GET", `/api/profiles/${encodeURIComponent(id)}`);
|
|
3817
|
+
if (res.status !== 200 || !res.body?.id) refuse(`Unknown profile "${requested}".`);
|
|
3818
|
+
return res.body;
|
|
3819
|
+
}
|
|
3820
|
+
function scalarRecord(label, value) {
|
|
3821
|
+
if (value === void 0 || value === null) return {};
|
|
3822
|
+
if (typeof value !== "object" || Array.isArray(value)) refuse(`\`${label}\` must be an object.`);
|
|
3823
|
+
const out = {};
|
|
3824
|
+
for (const [k, v] of Object.entries(value)) {
|
|
3825
|
+
if (typeof v !== "string" && typeof v !== "number") refuse(`\`${label}.${k}\` must be a string or a number.`);
|
|
3826
|
+
out[k] = v;
|
|
3827
|
+
}
|
|
3828
|
+
return out;
|
|
3829
|
+
}
|
|
3830
|
+
function approverPubkeys(body) {
|
|
3831
|
+
const map = body?.pubkeys;
|
|
3832
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return [];
|
|
3833
|
+
return Object.entries(map).filter((e) => typeof e[1] === "string" && e[1].length > 0).map(([userId, publicKey]) => ({ userId, publicKey }));
|
|
3834
|
+
}
|
|
3835
|
+
async function planMandate(req, deps) {
|
|
3836
|
+
const user = deps.user;
|
|
3837
|
+
if (!user) refuse("The gateway is not signed in.");
|
|
3838
|
+
const groupsRes = await deps.as("GET", "/api/groups");
|
|
3839
|
+
const groups = groupsRes.body?.groups ?? [];
|
|
3840
|
+
const group = req.team ? groups.find((g) => !g.isPersonal && (g.id === req.team || g.name === req.team)) : groups.find((g) => g.isPersonal);
|
|
3841
|
+
if (!group) refuse(req.team ? `You are not a member of a team "${req.team}".` : "No personal workspace found.");
|
|
3842
|
+
const isPersonal = !!group.isPersonal;
|
|
3843
|
+
const profile = await resolveProfile(deps.as, req.profile);
|
|
3844
|
+
const name = profile.name ?? shortOf(profile.id);
|
|
3845
|
+
let approvers = [];
|
|
3846
|
+
if (!isPersonal) {
|
|
3847
|
+
const cfg = await deps.as("GET", `/api/groups/${encodeURIComponent(group.id)}/profile-config/${encodeURIComponent(profile.id)}`);
|
|
3848
|
+
approvers = (cfg.status === 200 ? cfg.body?.config?.approvers : void 0) ?? [];
|
|
3849
|
+
if (approvers.length === 0) refuse(`${name} is not enabled in "${group.name}": a team admin must name who may give ${name} mandates.`);
|
|
3850
|
+
if (!approvers.includes(user.id)) refuse(`Only ${name}'s approvers in "${group.name}" can give this mandate, and the signed-in person is not one of them.`);
|
|
3851
|
+
}
|
|
3852
|
+
const bounds = { ...scalarRecord("limits", req.limits), profile: profile.id };
|
|
3853
|
+
const bv = validateBoundsParams(bounds, profile);
|
|
3854
|
+
const errors = [...bv.errors];
|
|
3855
|
+
for (const [field, value] of Object.entries(bounds)) {
|
|
3856
|
+
const bt = profile.boundsSchema?.fields[field]?.boundType;
|
|
3857
|
+
if (bt?.kind === "enum" && Array.isArray(bt.values) && !bt.values.includes(String(value))) {
|
|
3858
|
+
errors.push(`Field "${field}" must be one of ${bt.values.join(", ")}.`);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
const context = scalarRecord("scope", req.scope);
|
|
3862
|
+
errors.push(...validateContextParams(context, profile).errors);
|
|
3863
|
+
if (errors.length) refuse(`The mandate does not fit profile ${profile.id}: ${errors.join(" ")}`);
|
|
3864
|
+
if (!isCommitmentModeAllowed(profile, req.mode)) {
|
|
3865
|
+
refuse(`Profile ${profile.id} allows mode ${allowedCommitmentModes(profile).join(" or ") || "none"}, not "${req.mode}".`);
|
|
3866
|
+
}
|
|
3867
|
+
const ttlSeconds = req.durationHours === void 0 ? profile.ttl.default : Math.round(req.durationHours * 3600);
|
|
3868
|
+
if (!(ttlSeconds > 0)) refuse("`duration_hours` must be positive.");
|
|
3869
|
+
if (ttlSeconds > profile.ttl.max) refuse(`At most ${profile.ttl.max / 3600} hours for profile ${profile.id}.`);
|
|
3870
|
+
const intent = typeof req.intent === "string" ? req.intent.trim() : "";
|
|
3871
|
+
if (!intent) refuse("`intent` is required \u2014 the why, goal and watch-outs, in the person's words.");
|
|
3872
|
+
if (intent.length > INTENT_MAX) refuse(`\`intent\` is ${intent.length} characters; the limit is ${INTENT_MAX}.`);
|
|
3873
|
+
return {
|
|
3874
|
+
groupId: group.id,
|
|
3875
|
+
groupName: group.name,
|
|
3876
|
+
isPersonal,
|
|
3877
|
+
domain: isPersonal ? "owner" : user.id,
|
|
3878
|
+
profile,
|
|
3879
|
+
bounds,
|
|
3880
|
+
context,
|
|
3881
|
+
intent,
|
|
3882
|
+
mode: req.mode,
|
|
3883
|
+
ttlSeconds,
|
|
3884
|
+
title: (req.title ?? "").trim() || `${name} (proposed by AI)`,
|
|
3885
|
+
approvers
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
async function createMandate(req, deps) {
|
|
3889
|
+
const plan = await planMandate(req, deps);
|
|
3890
|
+
const user = deps.user;
|
|
3891
|
+
const boundsHash = computeBoundsHash(plan.bounds, plan.profile);
|
|
3892
|
+
const contextHash = computeContextHash(plan.context, plan.profile);
|
|
3893
|
+
const intentHash = computeIntentHash(plan.intent);
|
|
3894
|
+
const ecHash = computeIntentHash(JSON.stringify({ profile: plan.profile.id, domain: plan.domain, group: plan.groupId }));
|
|
3895
|
+
let enc;
|
|
3896
|
+
if (!plan.isPersonal) {
|
|
3897
|
+
const keys = await deps.as(
|
|
3898
|
+
"GET",
|
|
3899
|
+
`/api/groups/${encodeURIComponent(plan.groupId)}/profile-config/${encodeURIComponent(plan.profile.id)}/approvers/pubkeys`
|
|
3900
|
+
);
|
|
3901
|
+
const recipients = approverPubkeys(keys.body);
|
|
3902
|
+
if (recipients.length > 0) enc = await deps.encrypt(plan.intent, recipients);
|
|
3903
|
+
}
|
|
3904
|
+
const authorizationId = deps.newAuthorizationId();
|
|
3905
|
+
const res = await deps.as("POST", "/api/as/attest", {
|
|
3906
|
+
authorization_id: authorizationId,
|
|
3907
|
+
profile_id: plan.profile.id,
|
|
3908
|
+
bounds: plan.bounds,
|
|
3909
|
+
bounds_hash: boundsHash,
|
|
3910
|
+
context_hash: contextHash,
|
|
3911
|
+
domain: plan.domain,
|
|
3912
|
+
did: user.did,
|
|
3913
|
+
gate_content_hashes: { intent: intentHash },
|
|
3914
|
+
execution_context_hash: ecHash,
|
|
3915
|
+
group_id: plan.groupId,
|
|
3916
|
+
ttl: plan.ttlSeconds,
|
|
3917
|
+
commitment_mode: plan.mode,
|
|
3918
|
+
title: plan.title,
|
|
3919
|
+
intent_ciphertext: enc?.intentCiphertext,
|
|
3920
|
+
encrypted_keys: enc?.encryptedKeys,
|
|
3921
|
+
approvers_frozen: enc?.approversFrozen,
|
|
3922
|
+
intent_disclosure_hash: enc?.intentDisclosureHash
|
|
3923
|
+
});
|
|
3924
|
+
if (res.status >= 300) {
|
|
3925
|
+
const why = res.body?.message ?? res.body?.detail ?? res.body?.error ?? `status ${res.status}`;
|
|
3926
|
+
refuse(`The Authority Server refused the mandate: ${why}`);
|
|
3927
|
+
}
|
|
3928
|
+
try {
|
|
3929
|
+
await deps.deliverGateContent({ authorizationId, boundsHash, contextHash, context: plan.context, gateContent: { intent: plan.intent } });
|
|
3930
|
+
} catch (err) {
|
|
3931
|
+
await deps.as("POST", `/api/authorizations/${encodeURIComponent(authorizationId)}/revoke`, {
|
|
3932
|
+
reason: "Auto-revoked: gate content delivery failed"
|
|
3933
|
+
}).catch(() => {
|
|
3934
|
+
});
|
|
3935
|
+
refuse(`The mandate was signed but its intent could not be delivered to the gateway, so it was revoked. (${err instanceof Error ? err.message : String(err)})`);
|
|
3936
|
+
}
|
|
3937
|
+
return { authorizationId, plan };
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// src/routes/internal-mandate.ts
|
|
3941
|
+
function secretMatches2(provided, expected) {
|
|
3942
|
+
if (!expected || !provided) return false;
|
|
3943
|
+
const a = Buffer.from(provided);
|
|
3944
|
+
const b = Buffer.from(expected);
|
|
3945
|
+
return a.length === b.length && timingSafeEqual3(a, b);
|
|
3946
|
+
}
|
|
3947
|
+
function createInternalMandateRouter(getSecret, deps) {
|
|
3948
|
+
const router = Router15();
|
|
3949
|
+
router.post("/mandate", async (req, res) => {
|
|
3950
|
+
if (!secretMatches2(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
|
|
3951
|
+
res.status(401).json({ error: "unauthorized" });
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3954
|
+
const { dryRun, request } = req.body ?? {};
|
|
3955
|
+
if (!request || typeof request !== "object") {
|
|
3956
|
+
res.status(400).json({ error: "missing request" });
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
const d = { ...deps(), newAuthorizationId: () => `authz_${randomUUID()}` };
|
|
3960
|
+
try {
|
|
3961
|
+
if (dryRun === true) {
|
|
3962
|
+
const plan2 = await planMandate(request, d);
|
|
3963
|
+
res.json({ ok: true, groupName: plan2.groupName, profileId: plan2.profile.id, mode: plan2.mode, ttlSeconds: plan2.ttlSeconds });
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
const { authorizationId, plan } = await createMandate(request, d);
|
|
3967
|
+
res.json({ ok: true, authorizationId, profileId: plan.profile.id, groupName: plan.groupName, mode: plan.mode, ttlSeconds: plan.ttlSeconds });
|
|
3968
|
+
} catch (err) {
|
|
3969
|
+
if (err instanceof MandateRefused) {
|
|
3970
|
+
res.status(422).json({ error: "mandate_refused", message: err.message });
|
|
3971
|
+
return;
|
|
3972
|
+
}
|
|
3973
|
+
console.error("[Control Plane] /internal/mandate failed:", err);
|
|
3974
|
+
res.status(500).json({ error: "internal", message: err instanceof Error ? err.message : String(err) });
|
|
3975
|
+
}
|
|
3976
|
+
});
|
|
3977
|
+
return router;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3760
3980
|
// src/lib/notification-dispatcher.ts
|
|
3761
3981
|
var TRIGGER_EVENTS = ["proposal-added", "action-approval-needed"];
|
|
3762
3982
|
var TITLE = "Suveren";
|
|
@@ -4273,6 +4493,33 @@ app.get("/auth/oauth/:integrationId/health", authGuard, async (req, res) => {
|
|
|
4273
4493
|
});
|
|
4274
4494
|
app.get("/events", requireAllowedHost, requireAuthQueryOrHeader(vault), createEventsHandler());
|
|
4275
4495
|
app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch, lockAsTlsMismatch));
|
|
4496
|
+
app.use("/internal", jsonParser, createInternalMandateRouter(() => internalSecret2, () => ({
|
|
4497
|
+
user: vault.getSessionUser(),
|
|
4498
|
+
as: async (method, path, body) => {
|
|
4499
|
+
const cookie = vault.getSpCookie();
|
|
4500
|
+
if (!cookie) return { status: 401, body: { error: "not signed in" } };
|
|
4501
|
+
const pin = readPairing(DATA_DIR);
|
|
4502
|
+
try {
|
|
4503
|
+
const { res } = await fetchAs(
|
|
4504
|
+
`${SP_URL}${path}`,
|
|
4505
|
+
{
|
|
4506
|
+
method,
|
|
4507
|
+
headers: { Cookie: cookie, ...body !== void 0 ? { "Content-Type": "application/json" } : {} },
|
|
4508
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
4509
|
+
signal: AbortSignal.timeout(15e3)
|
|
4510
|
+
},
|
|
4511
|
+
{ enforce: resolvePinTls(DATA_DIR), pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0 }
|
|
4512
|
+
);
|
|
4513
|
+
if (res.status === 401 && vault.isUnlocked()) lockExpiredSession();
|
|
4514
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
4515
|
+
} catch (err) {
|
|
4516
|
+
if (err instanceof AsTlsMismatchError) lockAsTlsMismatch();
|
|
4517
|
+
throw err;
|
|
4518
|
+
}
|
|
4519
|
+
},
|
|
4520
|
+
encrypt: encryptIntentForRecipients,
|
|
4521
|
+
deliverGateContent: (args) => pushGateContent(args)
|
|
4522
|
+
})));
|
|
4276
4523
|
app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
|
|
4277
4524
|
app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
|
|
4278
4525
|
app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
|
|
@@ -4466,6 +4713,14 @@ app.post("/gate-content", jsonParser, authGuard, async (req, res) => {
|
|
|
4466
4713
|
res.status(500).json({ error: "Failed to forward gate content to MCP server" });
|
|
4467
4714
|
}
|
|
4468
4715
|
});
|
|
4716
|
+
app.get("/tool-display", authGuard, async (_req, res) => {
|
|
4717
|
+
try {
|
|
4718
|
+
res.json(await getToolDisplay());
|
|
4719
|
+
} catch (err) {
|
|
4720
|
+
console.error("[Control Plane] tool-display error:", err);
|
|
4721
|
+
res.status(502).json({ error: "MCP server unavailable" });
|
|
4722
|
+
}
|
|
4723
|
+
});
|
|
4469
4724
|
app.post("/resync-gates", authGuard, async (_req, res) => {
|
|
4470
4725
|
try {
|
|
4471
4726
|
const data = await resyncGates();
|
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -2984,7 +2984,7 @@ function createGatedToolHandler(tool, integrationManager2, state2) {
|
|
|
2984
2984
|
const gateable = !!tool.gating?.profile && tool.gating.category !== "disabled";
|
|
2985
2985
|
const inner = async (args) => {
|
|
2986
2986
|
const normalized = normalizeIncomingArgs(tool, args);
|
|
2987
|
-
const refusal = gateable ? integrationManager2.precheckBuiltin?.(tool, normalized) : null;
|
|
2987
|
+
const refusal = gateable ? await integrationManager2.precheckBuiltin?.(tool, normalized) : null;
|
|
2988
2988
|
if (refusal) return { content: [{ type: "text", text: refusal }], isError: true };
|
|
2989
2989
|
return gated(normalized);
|
|
2990
2990
|
};
|
|
@@ -5248,6 +5248,18 @@ var IntegrationManager = class {
|
|
|
5248
5248
|
available: !def.simulationOnly || isSimulationMode()
|
|
5249
5249
|
}));
|
|
5250
5250
|
}
|
|
5251
|
+
/**
|
|
5252
|
+
* What the approval screen needs to show a proposal for each tool: its argument
|
|
5253
|
+
* schema (labels from the descriptions) and the optional approvalView hints.
|
|
5254
|
+
* Keyed by the namespaced tool name a proposal carries.
|
|
5255
|
+
*/
|
|
5256
|
+
getToolDisplay() {
|
|
5257
|
+
const out = {};
|
|
5258
|
+
for (const t of this.getAllTools()) {
|
|
5259
|
+
out[t.namespacedName] = { inputSchema: t.inputSchema, ...t.gating?.approvalView ? { approvalView: t.gating.approvalView } : {} };
|
|
5260
|
+
}
|
|
5261
|
+
return out;
|
|
5262
|
+
}
|
|
5251
5263
|
/** Whether `id` names a built-in (in-process) integration. */
|
|
5252
5264
|
isBuiltin(id) {
|
|
5253
5265
|
return this.builtins.has(id);
|
|
@@ -5266,14 +5278,14 @@ var IntegrationManager = class {
|
|
|
5266
5278
|
* no ticket): `simulationOnly` outside simulation mode, then the tool's `validate`.
|
|
5267
5279
|
* Null for connector tools and for calls that may proceed.
|
|
5268
5280
|
*/
|
|
5269
|
-
precheckBuiltin(tool, args) {
|
|
5281
|
+
async precheckBuiltin(tool, args) {
|
|
5270
5282
|
const builtin = this.builtins.get(tool.integrationId);
|
|
5271
5283
|
if (!builtin) return null;
|
|
5272
5284
|
if (builtin.def.simulationOnly && !isSimulationMode()) {
|
|
5273
5285
|
return `Refused: "${tool.namespacedName}" is not available. No ticket was requested.`;
|
|
5274
5286
|
}
|
|
5275
5287
|
const def = builtin.def.tools.find((t) => t.name === tool.originalName);
|
|
5276
|
-
const refusal = def?.validate?.(args);
|
|
5288
|
+
const refusal = await def?.validate?.(args);
|
|
5277
5289
|
return refusal ? `Refused: ${refusal} No ticket was requested.` : null;
|
|
5278
5290
|
}
|
|
5279
5291
|
/**
|
|
@@ -5484,7 +5496,8 @@ var IntegrationManager = class {
|
|
|
5484
5496
|
readGovernance: ext.readGovernance,
|
|
5485
5497
|
readGovernanceReason: ext.readGovernanceReason,
|
|
5486
5498
|
blockedArgs: ext.blockedArgs,
|
|
5487
|
-
hideUnlessAuthorized: ext.hideUnlessAuthorized
|
|
5499
|
+
hideUnlessAuthorized: ext.hideUnlessAuthorized,
|
|
5500
|
+
approvalView: ext.approvalView
|
|
5488
5501
|
};
|
|
5489
5502
|
}
|
|
5490
5503
|
return {
|
|
@@ -5499,7 +5512,8 @@ var IntegrationManager = class {
|
|
|
5499
5512
|
blockedArgs: ext.blockedArgs,
|
|
5500
5513
|
argEncoding: ext.argEncoding,
|
|
5501
5514
|
argNormalization: ext.argNormalization,
|
|
5502
|
-
hideUnlessAuthorized: ext.hideUnlessAuthorized
|
|
5515
|
+
hideUnlessAuthorized: ext.hideUnlessAuthorized,
|
|
5516
|
+
approvalView: ext.approvalView
|
|
5503
5517
|
};
|
|
5504
5518
|
}
|
|
5505
5519
|
return {
|
|
@@ -5606,6 +5620,27 @@ function builtinText(text) {
|
|
|
5606
5620
|
return { content: [{ type: "text", text }] };
|
|
5607
5621
|
}
|
|
5608
5622
|
|
|
5623
|
+
// src/lib/cp-mandate.ts
|
|
5624
|
+
var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
|
|
5625
|
+
var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
|
|
5626
|
+
async function controlPlaneMandate(dryRun, request) {
|
|
5627
|
+
const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
|
|
5628
|
+
if (!secret) return { ok: false, message: "the gateway is not fully started (no control plane connection)." };
|
|
5629
|
+
try {
|
|
5630
|
+
const res = await fetch(`${CP_BASE2}/internal/mandate`, {
|
|
5631
|
+
method: "POST",
|
|
5632
|
+
headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
|
|
5633
|
+
body: JSON.stringify({ dryRun, request }),
|
|
5634
|
+
signal: AbortSignal.timeout(3e4)
|
|
5635
|
+
});
|
|
5636
|
+
const body = await res.json().catch(() => ({}));
|
|
5637
|
+
if (res.ok) return { ok: true, ...body };
|
|
5638
|
+
return { ok: false, message: typeof body.message === "string" ? body.message : `control plane answered ${res.status}` };
|
|
5639
|
+
} catch (err) {
|
|
5640
|
+
return { ok: false, message: `the control plane could not be reached (${err instanceof Error ? err.message : String(err)}).` };
|
|
5641
|
+
}
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5609
5644
|
// src/lib/builtins/setup.ts
|
|
5610
5645
|
var DELEGATION_PROFILE = "github.com/humanagencyprotocol/hap-profiles/delegation@0.1";
|
|
5611
5646
|
function briefRefusal(args) {
|
|
@@ -5618,7 +5653,7 @@ function setupBuiltin(_deps) {
|
|
|
5618
5653
|
return {
|
|
5619
5654
|
id: "setup",
|
|
5620
5655
|
name: "Test setup",
|
|
5621
|
-
description: "Your AI proposes its own setup for a test \u2014
|
|
5656
|
+
description: "Your AI proposes its own setup for a test \u2014 its agent brief and its mandates. Every proposal waits for your approval. Simulation mode only.",
|
|
5622
5657
|
profile: DELEGATION_PROFILE,
|
|
5623
5658
|
simulation: true,
|
|
5624
5659
|
simulationOnly: true,
|
|
@@ -5627,7 +5662,24 @@ function setupBuiltin(_deps) {
|
|
|
5627
5662
|
set_agent_brief: {
|
|
5628
5663
|
executionMapping: {},
|
|
5629
5664
|
staticExecution: { action_type: "brief" },
|
|
5630
|
-
hideUnlessAuthorized: true
|
|
5665
|
+
hideUnlessAuthorized: true,
|
|
5666
|
+
approvalView: { content: { label: "New agent brief", kind: "markdown" } }
|
|
5667
|
+
},
|
|
5668
|
+
create_mandate: {
|
|
5669
|
+
executionMapping: {},
|
|
5670
|
+
staticExecution: { action_type: "mandate" },
|
|
5671
|
+
hideUnlessAuthorized: true,
|
|
5672
|
+
// Shown like the mandate screen: limits and scope by the profile's own names.
|
|
5673
|
+
approvalView: {
|
|
5674
|
+
team: { label: "Team" },
|
|
5675
|
+
profile: { label: "Profile", kind: "profile" },
|
|
5676
|
+
limits: { label: "Limits", kind: "profile-limits", profileArg: "profile" },
|
|
5677
|
+
scope: { label: "Scope", kind: "profile-scope", profileArg: "profile" },
|
|
5678
|
+
intent: { label: "Intent", kind: "markdown" },
|
|
5679
|
+
mode: { label: "Mode" },
|
|
5680
|
+
duration_hours: { label: "Valid for (hours)" },
|
|
5681
|
+
title: { label: "Title" }
|
|
5682
|
+
}
|
|
5631
5683
|
}
|
|
5632
5684
|
}
|
|
5633
5685
|
},
|
|
@@ -5656,10 +5708,56 @@ function setupBuiltin(_deps) {
|
|
|
5656
5708
|
`Agent brief replaced (${Buffer.byteLength(args.content, "utf-8")} bytes). It applies from the next agent session.`
|
|
5657
5709
|
);
|
|
5658
5710
|
}
|
|
5711
|
+
},
|
|
5712
|
+
{
|
|
5713
|
+
name: "create_mandate",
|
|
5714
|
+
description: "Simulation mode only: propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). A person approves or rejects the proposal; only after approval is the mandate created, exactly as if they had signed it on the mandate screen. Checked against the profile before the proposal: unknown limits, a mode the profile does not allow, or a team where the person may not give this mandate are refused at once. Create only.",
|
|
5715
|
+
inputSchema: {
|
|
5716
|
+
type: "object",
|
|
5717
|
+
properties: {
|
|
5718
|
+
team: { type: "string", description: "Team name or id. Omit for the personal workspace." },
|
|
5719
|
+
profile: { type: "string", description: 'Profile, e.g. "sales" (newest version) or a full profile id.' },
|
|
5720
|
+
limits: { type: "object", description: "The limits (bounds) by field name, as the profile defines them." },
|
|
5721
|
+
scope: { type: "object", description: "The scope (context) by field name, as the profile defines it." },
|
|
5722
|
+
intent: { type: "string", description: "Why, goal and watch-outs \u2014 signed with the mandate (at most 2000 characters)." },
|
|
5723
|
+
mode: { type: "string", enum: ["review", "automatic"], description: "review = each action needs approval; automatic = within the limits." },
|
|
5724
|
+
duration_hours: { type: "number", description: "How long the mandate is valid, in hours. Omit for the profile default." },
|
|
5725
|
+
title: { type: "string", description: "A short name for the mandate." },
|
|
5726
|
+
receipt_id: {
|
|
5727
|
+
type: "string",
|
|
5728
|
+
description: "Authorization reference for this call, set by the governing gateway \u2014 agents do not set this."
|
|
5729
|
+
}
|
|
5730
|
+
},
|
|
5731
|
+
required: ["profile", "limits", "intent", "mode"]
|
|
5732
|
+
},
|
|
5733
|
+
validate: async (args) => {
|
|
5734
|
+
const check = await controlPlaneMandate(true, mandateRequest(args));
|
|
5735
|
+
return check.ok ? void 0 : check.message;
|
|
5736
|
+
},
|
|
5737
|
+
handler: async (args) => {
|
|
5738
|
+
if (!isSimulationMode()) return { ...builtinText("Refused: not available outside simulation mode."), isError: true };
|
|
5739
|
+
const r = await controlPlaneMandate(false, mandateRequest(args));
|
|
5740
|
+
if (!r.ok) return { ...builtinText(`Refused: ${r.message}`), isError: true };
|
|
5741
|
+
return builtinText(
|
|
5742
|
+
`Mandate created: ${r.authorizationId} \u2014 profile ${r.profileId}, ${r.groupName}, mode ${r.mode}, valid for ${Math.round((r.ttlSeconds ?? 0) / 3600)} h. It is active now.`
|
|
5743
|
+
);
|
|
5744
|
+
}
|
|
5659
5745
|
}
|
|
5660
5746
|
]
|
|
5661
5747
|
};
|
|
5662
5748
|
}
|
|
5749
|
+
function mandateRequest(args) {
|
|
5750
|
+
return {
|
|
5751
|
+
team: typeof args.team === "string" && args.team.trim() ? args.team.trim() : void 0,
|
|
5752
|
+
profile: args.profile,
|
|
5753
|
+
limits: args.limits,
|
|
5754
|
+
scope: args.scope,
|
|
5755
|
+
intent: args.intent,
|
|
5756
|
+
mode: args.mode,
|
|
5757
|
+
durationHours: args.duration_hours,
|
|
5758
|
+
title: args.title
|
|
5759
|
+
};
|
|
5760
|
+
}
|
|
5663
5761
|
|
|
5664
5762
|
// src/lib/builtins/index.ts
|
|
5665
5763
|
var BUILTIN_FACTORIES = [setupBuiltin];
|
|
@@ -6151,6 +6249,9 @@ app.get("/health", (_req, res) => {
|
|
|
6151
6249
|
builtins: integrationManager.getBuiltins()
|
|
6152
6250
|
});
|
|
6153
6251
|
});
|
|
6252
|
+
app.get("/internal/tool-display", internalOnly, (_req, res) => {
|
|
6253
|
+
res.json({ tools: integrationManager.getToolDisplay() });
|
|
6254
|
+
});
|
|
6154
6255
|
app.get("/internal/gate-content", internalOnly, (req, res) => {
|
|
6155
6256
|
const path = req.query.path;
|
|
6156
6257
|
const gates = state.gateStore.getAll();
|