@intentic/sandbox-contract 1.337.0 → 1.338.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/README.md +36 -0
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +4 -7
- package/dist/chores/probes.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +32 -23
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +6016 -5994
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +3 -0
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/approvals.contract.d.ts +8 -0
- package/dist/contracts/approvals.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.d.ts +3 -3
- package/dist/contracts/capabilities.contract.d.ts +28 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +11 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/device.contract.d.ts +225 -0
- package/dist/contracts/device.contract.d.ts.map +1 -1
- package/dist/contracts/device.contract.js +10 -0
- package/dist/contracts/device.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +16 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/needs.contract.d.ts +22 -7
- package/dist/contracts/needs.contract.d.ts.map +1 -1
- package/dist/contracts/org.contract.d.ts +35 -0
- package/dist/contracts/org.contract.d.ts.map +1 -0
- package/dist/contracts/org.contract.js +14 -0
- package/dist/contracts/org.contract.js.map +1 -0
- package/dist/contracts/privacy.contract.d.ts +115 -0
- package/dist/contracts/privacy.contract.d.ts.map +1 -1
- package/dist/contracts/privacy.contract.js +11 -2
- package/dist/contracts/privacy.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts +11 -7
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.d.ts +85 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.js +48 -1
- package/dist/contracts/secrets.contract.js.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +4 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +11 -3
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +20 -2
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +17 -4
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/requests.d.ts +4 -0
- package/dist/events/requests.d.ts.map +1 -1
- package/dist/events/requests.js +1 -1
- package/dist/events/requests.js.map +1 -1
- package/dist/events/system-events.d.ts +4 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/system-events.js +2 -0
- package/dist/events/system-events.js.map +1 -1
- package/dist/events/transcript.d.ts +27 -6
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/index.d.ts +7555 -7426
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -257
- package/dist/index.js.map +1 -1
- package/dist/policy/identity-assertion.d.ts.map +1 -1
- package/dist/policy/identity-assertion.js +2 -8
- package/dist/policy/identity-assertion.js.map +1 -1
- package/dist/policy/keyring-seal.d.ts +48 -0
- package/dist/policy/keyring-seal.d.ts.map +1 -0
- package/dist/policy/keyring-seal.js +94 -0
- package/dist/policy/keyring-seal.js.map +1 -0
- package/dist/policy/message-limits.d.ts +2 -0
- package/dist/policy/message-limits.d.ts.map +1 -1
- package/dist/policy/message-limits.js +2 -0
- package/dist/policy/message-limits.js.map +1 -1
- package/dist/policy/org-locks.d.ts +58 -0
- package/dist/policy/org-locks.d.ts.map +1 -0
- package/dist/policy/org-locks.js +113 -0
- package/dist/policy/org-locks.js.map +1 -0
- package/dist/policy/reserved-servers.d.ts.map +1 -1
- package/dist/policy/reserved-servers.js +1 -1
- package/dist/policy/reserved-servers.js.map +1 -1
- package/dist/protocol/doors/webext-links.d.ts +3 -0
- package/dist/protocol/doors/webext-links.d.ts.map +1 -1
- package/dist/protocol/doors/webext-links.js +3 -0
- package/dist/protocol/doors/webext-links.js.map +1 -1
- package/dist/protocol/raw/raw-json-routes.d.ts +269 -0
- package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-json-routes.js +14 -1
- package/dist/protocol/raw/raw-json-routes.js.map +1 -1
- package/dist/protocol/raw/raw-routes.d.ts +70 -0
- package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-routes.js +18 -0
- package/dist/protocol/raw/raw-routes.js.map +1 -1
- package/dist/protocol/route-meta.d.ts.map +1 -1
- package/dist/protocol/route-meta.js.map +1 -1
- package/dist/protocol/routes.d.ts +4 -0
- package/dist/protocol/routes.d.ts.map +1 -1
- package/dist/protocol/routes.js +12 -0
- package/dist/protocol/routes.js.map +1 -1
- package/dist/schemas/agents.d.ts +1 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +1 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/approvals.d.ts +20 -0
- package/dist/schemas/approvals.d.ts.map +1 -1
- package/dist/schemas/approvals.js +10 -0
- package/dist/schemas/approvals.js.map +1 -1
- package/dist/schemas/capabilities.d.ts +26 -0
- package/dist/schemas/capabilities.d.ts.map +1 -1
- package/dist/schemas/capabilities.js +17 -0
- package/dist/schemas/capabilities.js.map +1 -1
- package/dist/schemas/data-protection.d.ts +5 -0
- package/dist/schemas/data-protection.d.ts.map +1 -1
- package/dist/schemas/data-protection.js +29 -0
- package/dist/schemas/data-protection.js.map +1 -1
- package/dist/schemas/devices.d.ts +11 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +2 -1
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/extension-updates.d.ts +32 -0
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/git/git.d.ts +19 -0
- package/dist/schemas/git/git.d.ts.map +1 -1
- package/dist/schemas/git/git.js +12 -1
- package/dist/schemas/git/git.js.map +1 -1
- package/dist/schemas/hosts.d.ts +2 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +11 -1
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/keyring.d.ts +421 -0
- package/dist/schemas/keyring.d.ts.map +1 -0
- package/dist/schemas/keyring.js +176 -0
- package/dist/schemas/keyring.js.map +1 -0
- package/dist/schemas/links.d.ts +208 -0
- package/dist/schemas/links.d.ts.map +1 -0
- package/dist/schemas/links.js +116 -0
- package/dist/schemas/links.js.map +1 -0
- package/dist/schemas/maintenance.d.ts.map +1 -1
- package/dist/schemas/maintenance.js.map +1 -1
- package/dist/schemas/members.d.ts.map +1 -1
- package/dist/schemas/members.js +2 -0
- package/dist/schemas/members.js.map +1 -1
- package/dist/schemas/needs.d.ts +25 -8
- package/dist/schemas/needs.d.ts.map +1 -1
- package/dist/schemas/needs.js +15 -3
- package/dist/schemas/needs.js.map +1 -1
- package/dist/schemas/organisation.d.ts +106 -0
- package/dist/schemas/organisation.d.ts.map +1 -0
- package/dist/schemas/organisation.js +52 -0
- package/dist/schemas/organisation.js.map +1 -0
- package/dist/schemas/privacy.d.ts +232 -0
- package/dist/schemas/privacy.d.ts.map +1 -1
- package/dist/schemas/privacy.js +154 -4
- package/dist/schemas/privacy.js.map +1 -1
- package/dist/schemas/secrets.d.ts +151 -0
- package/dist/schemas/secrets.d.ts.map +1 -1
- package/dist/schemas/secrets.js +71 -8
- package/dist/schemas/secrets.js.map +1 -1
- package/dist/schemas/settings.d.ts +8 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +4 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/speech.d.ts +12 -0
- package/dist/schemas/speech.d.ts.map +1 -1
- package/dist/schemas/speech.js +23 -0
- package/dist/schemas/speech.js.map +1 -1
- package/dist/schemas/terminal.d.ts +15 -0
- package/dist/schemas/terminal.d.ts.map +1 -1
- package/dist/schemas/terminal.js +11 -2
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/schemas/voice.d.ts +3 -0
- package/dist/schemas/voice.d.ts.map +1 -1
- package/dist/schemas/voice.js +7 -0
- package/dist/schemas/voice.js.map +1 -1
- package/dist/schemas/webext.d.ts +17 -0
- package/dist/schemas/webext.d.ts.map +1 -1
- package/dist/schemas/webext.js +7 -1
- package/dist/schemas/webext.js.map +1 -1
- package/dist/state/contract-lock.d.ts +3 -1
- package/dist/state/contract-lock.d.ts.map +1 -1
- package/dist/state/contract-lock.js +34 -9
- package/dist/state/contract-lock.js.map +1 -1
- package/dist/state/contract-modules.d.ts +4 -0
- package/dist/state/contract-modules.d.ts.map +1 -0
- package/dist/state/contract-modules.js +28 -0
- package/dist/state/contract-modules.js.map +1 -0
- package/dist/state/daemon-floor.d.ts +4 -0
- package/dist/state/daemon-floor.d.ts.map +1 -0
- package/dist/state/daemon-floor.js +8 -0
- package/dist/state/daemon-floor.js.map +1 -0
- package/dist/state/definition.d.ts +34 -0
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +7 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/text/documents.d.ts +3 -1
- package/dist/text/documents.d.ts.map +1 -1
- package/dist/text/documents.js +30 -0
- package/dist/text/documents.js.map +1 -1
- package/dist/text/pages.d.ts.map +1 -1
- package/dist/text/pages.js +3 -7
- package/dist/text/pages.js.map +1 -1
- package/package.json +27 -5
- package/src/chores/probes.ts +4 -6
- package/src/contracts/agents.contract.ts +5 -0
- package/src/contracts/capabilities.contract.ts +14 -0
- package/src/contracts/device.contract.ts +30 -0
- package/src/contracts/org.contract.ts +19 -0
- package/src/contracts/privacy.contract.ts +13 -1
- package/src/contracts/secrets.contract.ts +64 -0
- package/src/events/requests.ts +3 -1
- package/src/events/system-events.ts +9 -0
- package/src/index.ts +41 -279
- package/src/policy/identity-assertion.ts +2 -8
- package/src/policy/keyring-seal.test.ts +93 -0
- package/src/policy/keyring-seal.ts +148 -0
- package/src/policy/message-limits.ts +3 -0
- package/src/policy/org-locks.ts +173 -0
- package/src/policy/reserved-servers.ts +3 -2
- package/src/policy/tests/org-locks.test.ts +92 -0
- package/src/policy/tests/reserved-servers.test.ts +3 -2
- package/src/protocol/doors/listener-protocol.ts +1 -1
- package/src/protocol/doors/webext-links.ts +13 -0
- package/src/protocol/ingress-contract.fixture.json +2 -0
- package/src/protocol/pairing-redeem.ts +1 -1
- package/src/protocol/peer-dial.test.ts +6 -6
- package/src/protocol/peer-mcp-server.test.ts +2 -1
- package/src/protocol/raw/raw-json-routes.ts +31 -1
- package/src/protocol/raw/raw-routes.ts +35 -1
- package/src/protocol/route-meta.ts +2 -1
- package/src/protocol/routes.test.ts +33 -1
- package/src/protocol/routes.ts +18 -0
- package/src/protocol/sse.ts +1 -1
- package/src/schemas/agent.ts +1 -1
- package/src/schemas/agents.ts +1 -0
- package/src/schemas/approvals.ts +14 -0
- package/src/schemas/capabilities.ts +25 -0
- package/src/schemas/data-protection.ts +47 -0
- package/src/schemas/devices.ts +8 -1
- package/src/schemas/git/git.ts +22 -2
- package/src/schemas/hosts.ts +14 -1
- package/src/schemas/keyring.ts +277 -0
- package/src/schemas/links.ts +180 -0
- package/src/schemas/maintenance.ts +1 -0
- package/src/schemas/members.ts +4 -0
- package/src/schemas/needs.ts +21 -5
- package/src/schemas/organisation.ts +110 -0
- package/src/schemas/privacy.test.ts +63 -0
- package/src/schemas/privacy.ts +213 -5
- package/src/schemas/secrets.ts +96 -8
- package/src/schemas/settings.ts +8 -0
- package/src/schemas/speech.ts +38 -1
- package/src/schemas/terminal.ts +13 -2
- package/src/schemas/voice.ts +11 -0
- package/src/schemas/webext.ts +16 -1
- package/src/state/contract-lock.test.ts +12 -12
- package/src/state/contract-lock.ts +43 -12
- package/src/state/contract-modules.ts +41 -0
- package/src/state/daemon-floor.test.ts +81 -0
- package/src/state/daemon-floor.ts +32 -0
- package/src/state/history-state.ts +14 -0
- package/src/state/workspace-state.ts +1 -1
- package/src/text/documents.test.ts +30 -1
- package/src/text/documents.ts +43 -1
- package/src/text/pages.ts +5 -7
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { KeyringGrant } from "../schemas/keyring.js";
|
|
2
|
+
import { grantNonce, keyFingerprint, newSealKeyPair, newSignKeyPair, seal, signGrant, unseal, verifyGrant } from "./keyring-seal.js";
|
|
3
|
+
|
|
4
|
+
const SANDBOX = "https://a1b2c3d4e5f6.intentic.dev";
|
|
5
|
+
|
|
6
|
+
const grantAt = (now: number, over: Partial<KeyringGrant> = {}): KeyringGrant => ({
|
|
7
|
+
v: 1,
|
|
8
|
+
sandbox: SANDBOX,
|
|
9
|
+
secret: "VPN_FORTI",
|
|
10
|
+
target: { kind: "window", programs: ["FortiClient.exe"], anyField: false },
|
|
11
|
+
purpose: "agent",
|
|
12
|
+
detail: "FortiClient sign-in",
|
|
13
|
+
nonce: grantNonce(),
|
|
14
|
+
issuedAt: now,
|
|
15
|
+
expiresAt: now + 60_000,
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("sealed boxes", () => {
|
|
20
|
+
test("open for their own key and purpose", () => {
|
|
21
|
+
const pair = newSealKeyPair();
|
|
22
|
+
const purpose = { kind: "release", sandbox: SANDBOX, secret: "VPN_FORTI", nonce: grantNonce() } as const;
|
|
23
|
+
const box = seal(pair.publicKey, "hunter2-but-longer", purpose);
|
|
24
|
+
expect(unseal(pair, box, purpose)).toBe("hunter2-but-longer");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("refuse another key", () => {
|
|
28
|
+
const purpose = { kind: "inbox", sandbox: SANDBOX, secret: "X" } as const;
|
|
29
|
+
const box = seal(newSealKeyPair().publicKey, "value", purpose);
|
|
30
|
+
expect(() => unseal(newSealKeyPair(), box, purpose)).toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("refuse another purpose, so one release cannot stand in for another", () => {
|
|
34
|
+
const pair = newSealKeyPair();
|
|
35
|
+
const nonce = grantNonce();
|
|
36
|
+
const box = seal(pair.publicKey, "value", { kind: "release", sandbox: SANDBOX, secret: "A", nonce });
|
|
37
|
+
expect(() => unseal(pair, box, { kind: "release", sandbox: SANDBOX, secret: "B", nonce })).toThrow();
|
|
38
|
+
expect(() => unseal(pair, box, { kind: "type", sandbox: SANDBOX, secret: "A", nonce })).toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("are fresh every time, so equal values do not look equal", () => {
|
|
42
|
+
const pair = newSealKeyPair();
|
|
43
|
+
const purpose = { kind: "inbox", sandbox: SANDBOX, secret: "X" } as const;
|
|
44
|
+
expect(seal(pair.publicKey, "same", purpose).data).not.toBe(seal(pair.publicKey, "same", purpose).data);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("grants", () => {
|
|
49
|
+
test("verify under the signing key", () => {
|
|
50
|
+
const pair = newSignKeyPair();
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const verdict = verifyGrant(pair.publicKey, signGrant(pair, grantAt(now)), now + 1000);
|
|
53
|
+
expect(verdict.ok).toBe(true);
|
|
54
|
+
expect(verdict.ok && verdict.grant.secret).toBe("VPN_FORTI");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("refuse another sandbox's key", () => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const verdict = verifyGrant(newSignKeyPair().publicKey, signGrant(newSignKeyPair(), grantAt(now)), now);
|
|
60
|
+
expect(verdict).toMatchObject({ ok: false, code: "bad-grant" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("refuse a payload changed after signing", () => {
|
|
64
|
+
const pair = newSignKeyPair();
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const signed = signGrant(pair, grantAt(now));
|
|
67
|
+
// SAFETY: the payload is the grant signGrant just serialised, so it parses back to one.
|
|
68
|
+
const body = JSON.parse(Buffer.from(signed.payload, "base64url").toString("utf8")) as KeyringGrant;
|
|
69
|
+
const changed = Buffer.from(JSON.stringify({ ...body, secret: "BANK" }), "utf8").toString("base64url");
|
|
70
|
+
expect(verifyGrant(pair.publicKey, { ...signed, payload: changed }, now)).toMatchObject({ ok: false, code: "bad-grant" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("expire", () => {
|
|
74
|
+
const pair = newSignKeyPair();
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
expect(verifyGrant(pair.publicKey, signGrant(pair, grantAt(now)), now + 61_000)).toMatchObject({ ok: false, code: "expired" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("are never signed for longer than two minutes", () => {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
expect(() => signGrant(newSignKeyPair(), grantAt(now, { expiresAt: now + 5 * 60_000 }))).toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("from far in the future are not this sandbox's", () => {
|
|
85
|
+
const pair = newSignKeyPair();
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
expect(verifyGrant(pair.publicKey, signGrant(pair, grantAt(now + 10 * 60_000)), now)).toMatchObject({ ok: false, code: "bad-grant" });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("fingerprints read in groups of four", () => {
|
|
92
|
+
expect(keyFingerprint(newSealKeyPair().publicKey)).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/u);
|
|
93
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, sign, verify, type KeyObject } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
KEYRING_GRANT_TTL_MAX_MS,
|
|
5
|
+
KEYRING_KEY_RE,
|
|
6
|
+
KeyringGrantSchema,
|
|
7
|
+
type KeyringGrant,
|
|
8
|
+
type SealedBox,
|
|
9
|
+
SealedBoxSchema,
|
|
10
|
+
type SignedKeyringGrant,
|
|
11
|
+
} from "../schemas/keyring.js";
|
|
12
|
+
|
|
13
|
+
// The two primitives a device-held secret rides on (schemas/keyring.ts): a SEALED BOX, a value only the holder of one
|
|
14
|
+
// X25519 key can open, and a GRANT, the sandbox's Ed25519-signed word for one use of one value. Shared by the daemon,
|
|
15
|
+
// which signs grants and opens what is sealed to it, and the keyring, which verifies grants and seals; one file so the
|
|
16
|
+
// two cannot drift on an encoding.
|
|
17
|
+
|
|
18
|
+
const SEAL_INFO = Buffer.from("intentic-keyring/seal/v1", "utf8");
|
|
19
|
+
const GRANT_DOMAIN = Buffer.from("intentic-keyring/grant/v1\0", "utf8");
|
|
20
|
+
// A clock on either side may run a little ahead; a grant from much further in the future is not one this sandbox made.
|
|
21
|
+
const GRANT_SKEW_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
// A key pair as it is stored: both halves raw, base64url, the `x` and `d` of their JWK.
|
|
24
|
+
export interface KeyringKeyPair {
|
|
25
|
+
readonly publicKey: string;
|
|
26
|
+
readonly privateKey: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Curve = "X25519" | "Ed25519";
|
|
30
|
+
|
|
31
|
+
// The halves of an OKP key's JWK, which Node always writes for a private key it just made.
|
|
32
|
+
const OkpJwkSchema = z.object({ x: z.string().regex(KEYRING_KEY_RE), d: z.string().regex(KEYRING_KEY_RE) });
|
|
33
|
+
|
|
34
|
+
const pairOf = (curve: Curve): KeyringKeyPair => {
|
|
35
|
+
const { privateKey } = curve === "X25519" ? generateKeyPairSync("x25519") : generateKeyPairSync("ed25519");
|
|
36
|
+
const jwk = OkpJwkSchema.parse(privateKey.export({ format: "jwk" }));
|
|
37
|
+
return { publicKey: jwk.x, privateKey: jwk.d };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const newSealKeyPair = (): KeyringKeyPair => pairOf("X25519");
|
|
41
|
+
export const newSignKeyPair = (): KeyringKeyPair => pairOf("Ed25519");
|
|
42
|
+
|
|
43
|
+
const publicKeyOf = (curve: Curve, x: string): KeyObject => createPublicKey({ key: { kty: "OKP", crv: curve, x }, format: "jwk" });
|
|
44
|
+
const privateKeyOf = (curve: Curve, pair: KeyringKeyPair): KeyObject =>
|
|
45
|
+
createPrivateKey({ key: { kty: "OKP", crv: curve, x: pair.publicKey, d: pair.privateKey }, format: "jwk" });
|
|
46
|
+
|
|
47
|
+
// What a box is sealed FOR, mixed into its authentication tag: a box opened under any other purpose fails, so a value
|
|
48
|
+
// released for one grant cannot be presented as another's, nor a typing payload as an inbox item.
|
|
49
|
+
export type SealPurpose =
|
|
50
|
+
| { readonly kind: "release"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
|
|
51
|
+
| { readonly kind: "type"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
|
|
52
|
+
| { readonly kind: "inbox"; readonly sandbox: string; readonly secret: string }
|
|
53
|
+
| { readonly kind: "copy"; readonly sandbox: string; readonly secret: string; readonly updatedAt: number }
|
|
54
|
+
| { readonly kind: "sync"; readonly account: string; readonly device: string; readonly revision: number };
|
|
55
|
+
|
|
56
|
+
export const sealAad = (purpose: SealPurpose): Buffer => {
|
|
57
|
+
switch (purpose.kind) {
|
|
58
|
+
case "release":
|
|
59
|
+
case "type":
|
|
60
|
+
return Buffer.from(`${purpose.kind}\0${purpose.sandbox}\0${purpose.secret}\0${purpose.nonce}`, "utf8");
|
|
61
|
+
case "inbox":
|
|
62
|
+
return Buffer.from(`inbox\0${purpose.sandbox}\0${purpose.secret}`, "utf8");
|
|
63
|
+
case "copy":
|
|
64
|
+
return Buffer.from(`copy\0${purpose.sandbox}\0${purpose.secret}\0${String(purpose.updatedAt)}`, "utf8");
|
|
65
|
+
case "sync":
|
|
66
|
+
return Buffer.from(`sync\0${purpose.account}\0${purpose.device}\0${String(purpose.revision)}`, "utf8");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const boxKey = (shared: Buffer, epk: string, recipient: string): Buffer =>
|
|
71
|
+
Buffer.from(hkdfSync("sha256", shared, Buffer.concat([Buffer.from(epk, "base64url"), Buffer.from(recipient, "base64url")]), SEAL_INFO, 32));
|
|
72
|
+
|
|
73
|
+
// Seals `plaintext` to the holder of `recipient`'s private half, for `purpose` alone.
|
|
74
|
+
export const seal = (recipient: string, plaintext: string, purpose: SealPurpose): SealedBox => {
|
|
75
|
+
const ephemeral = newSealKeyPair();
|
|
76
|
+
const shared = diffieHellman({ privateKey: privateKeyOf("X25519", ephemeral), publicKey: publicKeyOf("X25519", recipient) });
|
|
77
|
+
const iv = randomBytes(12);
|
|
78
|
+
const cipher = createCipheriv("aes-256-gcm", boxKey(shared, ephemeral.publicKey, recipient), iv);
|
|
79
|
+
cipher.setAAD(sealAad(purpose));
|
|
80
|
+
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
81
|
+
return { v: 1, epk: ephemeral.publicKey, iv: iv.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"), data: data.toString("base64url") };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Opens a box sealed to `pair` for `purpose`; throws on any way of not being one (malformed, another key, another purpose).
|
|
85
|
+
export const unseal = (pair: KeyringKeyPair, box: SealedBox, purpose: SealPurpose): string => {
|
|
86
|
+
const parsed = SealedBoxSchema.parse(box);
|
|
87
|
+
const shared = diffieHellman({ privateKey: privateKeyOf("X25519", pair), publicKey: publicKeyOf("X25519", parsed.epk) });
|
|
88
|
+
const decipher = createDecipheriv("aes-256-gcm", boxKey(shared, parsed.epk, pair.publicKey), Buffer.from(parsed.iv, "base64url"));
|
|
89
|
+
decipher.setAAD(sealAad(purpose));
|
|
90
|
+
decipher.setAuthTag(Buffer.from(parsed.tag, "base64url"));
|
|
91
|
+
return Buffer.concat([decipher.update(Buffer.from(parsed.data, "base64url")), decipher.final()]).toString("utf8");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// A fresh grant's nonce: 16 random bytes, base64url.
|
|
95
|
+
export const grantNonce = (): string => randomBytes(16).toString("base64url");
|
|
96
|
+
|
|
97
|
+
export const signGrant = (pair: KeyringKeyPair, grant: KeyringGrant): SignedKeyringGrant => {
|
|
98
|
+
const body = KeyringGrantSchema.parse(grant);
|
|
99
|
+
if (body.expiresAt - body.issuedAt > KEYRING_GRANT_TTL_MAX_MS || body.expiresAt <= body.issuedAt) {
|
|
100
|
+
throw new Error(`a keyring grant lives at most ${String(KEYRING_GRANT_TTL_MAX_MS / 1000)} s`);
|
|
101
|
+
}
|
|
102
|
+
const payload = Buffer.from(JSON.stringify(body), "utf8");
|
|
103
|
+
const sig = sign(null, Buffer.concat([GRANT_DOMAIN, payload]), privateKeyOf("Ed25519", pair));
|
|
104
|
+
return { payload: payload.toString("base64url"), sig: sig.toString("base64url") };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type GrantVerdict =
|
|
108
|
+
| { readonly ok: true; readonly grant: KeyringGrant }
|
|
109
|
+
| { readonly ok: false; readonly code: "bad-grant" | "expired"; readonly reason: string };
|
|
110
|
+
|
|
111
|
+
// Verifies `signed` against the sandbox's pinned signing key at `nowMs`. The keyring checks the nonce, the share and
|
|
112
|
+
// the target against its own facts; this says only whether the sandbox said it, and said it recently.
|
|
113
|
+
export const verifyGrant = (signKey: string, signed: SignedKeyringGrant, nowMs: number): GrantVerdict => {
|
|
114
|
+
let payload: Buffer;
|
|
115
|
+
try {
|
|
116
|
+
payload = Buffer.from(signed.payload, "base64url");
|
|
117
|
+
if (!verify(null, Buffer.concat([GRANT_DOMAIN, payload]), publicKeyOf("Ed25519", signKey), Buffer.from(signed.sig, "base64url"))) {
|
|
118
|
+
return { ok: false, code: "bad-grant", reason: "the grant's signature is not the sandbox's" };
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, code: "bad-grant", reason: "the grant could not be read" };
|
|
122
|
+
}
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(payload.toString("utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false, code: "bad-grant", reason: "the grant is not JSON" };
|
|
128
|
+
}
|
|
129
|
+
const grant = KeyringGrantSchema.safeParse(parsed);
|
|
130
|
+
if (!grant.success) {
|
|
131
|
+
return { ok: false, code: "bad-grant", reason: "the grant is not one this keyring knows how to read" };
|
|
132
|
+
}
|
|
133
|
+
const { issuedAt, expiresAt } = grant.data;
|
|
134
|
+
if (expiresAt - issuedAt > KEYRING_GRANT_TTL_MAX_MS || expiresAt <= issuedAt || issuedAt > nowMs + GRANT_SKEW_MS) {
|
|
135
|
+
return { ok: false, code: "bad-grant", reason: "the grant's times are not ones this sandbox would sign" };
|
|
136
|
+
}
|
|
137
|
+
if (expiresAt <= nowMs) {
|
|
138
|
+
return { ok: false, code: "expired", reason: "the grant has expired: the sandbox asks again" };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, grant: grant.data };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// A short, readable fingerprint of a public key, for a person comparing what two screens show.
|
|
144
|
+
export const keyFingerprint = (key: string): string => {
|
|
145
|
+
const raw = Buffer.from(key, "base64url");
|
|
146
|
+
const hex = raw.subarray(0, 8).toString("hex").toUpperCase();
|
|
147
|
+
return hex.replace(/(.{4})(?=.)/gu, "$1-");
|
|
148
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export const MESSAGE_LIMITS = {
|
|
6
6
|
bluesky: 300,
|
|
7
7
|
discord: 2_000,
|
|
8
|
+
facebook: 63_206,
|
|
8
9
|
instagram: 2_200,
|
|
9
10
|
linkedin: 3_000,
|
|
10
11
|
mastodon: 500,
|
|
@@ -12,6 +13,8 @@ export const MESSAGE_LIMITS = {
|
|
|
12
13
|
slack: 4_000,
|
|
13
14
|
// sendMessage refuses anything longer outright.
|
|
14
15
|
telegram: 4_096,
|
|
16
|
+
// A video's caption; a comment under one stops at 150.
|
|
17
|
+
tiktok: 4_000,
|
|
15
18
|
// A linked device (what the WhatsApp extension is) sends up to 65,536; the 4,096 often quoted is the Business API's.
|
|
16
19
|
whatsapp: 65_536,
|
|
17
20
|
x: 280,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ORG_SECTIONS, type OrgPolicy, type OrgSection, orgSectionMode } from "../schemas/organisation.js";
|
|
2
|
+
|
|
3
|
+
// WHICH DAEMON ROUTES AN ORGANISATION'S LOCK COVERS, and the refusal the daemon's middleware hands a request to one of
|
|
4
|
+
// them (docs/architecture/organisations.md). Named route by route rather than by contract group: a group mixes the
|
|
5
|
+
// changes a lock is about with operations it is not (a probe, a reveal, a sign-in being cancelled, a person confirming
|
|
6
|
+
// their own AI-literacy notice), and a lock that broke those would be a lock nobody could live with. A route added
|
|
7
|
+
// later is therefore open until it is listed here, which is the failure a reviewer can see in this file's diff.
|
|
8
|
+
//
|
|
9
|
+
// Names are the contract's own: `group.procedure` for a typed route, `METHOD /path` for a raw one (RAW_ROUTES). The
|
|
10
|
+
// test beside this checks every name against the route tables, so a rename cannot leave a lock pointing at nothing.
|
|
11
|
+
|
|
12
|
+
interface SectionRoutes {
|
|
13
|
+
// Refused while the section is locked or hidden: every change the section's page makes.
|
|
14
|
+
readonly changes: readonly string[];
|
|
15
|
+
// Refused only while it is hidden: reads that put a value in front of a person, which hiding is meant to stop.
|
|
16
|
+
readonly reveals?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ORG_SECTION_ROUTES = {
|
|
20
|
+
models: { changes: ["accounts.start", "accounts.complete", "accounts.rename", "accounts.disconnect"] },
|
|
21
|
+
agent: {
|
|
22
|
+
// `privacy.learn` stays open: teaching the shield more values only ever adds protection.
|
|
23
|
+
changes: [
|
|
24
|
+
"settings.set",
|
|
25
|
+
"settings.setAgentDomain",
|
|
26
|
+
"privacy.setPolicy",
|
|
27
|
+
"privacy.forget",
|
|
28
|
+
"safety.setPolicy",
|
|
29
|
+
"skills.save",
|
|
30
|
+
"skills.remove",
|
|
31
|
+
],
|
|
32
|
+
reveals: ["privacy.reveal"],
|
|
33
|
+
},
|
|
34
|
+
// Rejecting a proposed tool and timing a rebuild stay open: neither changes what the sandbox is made of.
|
|
35
|
+
environment: {
|
|
36
|
+
changes: [
|
|
37
|
+
"POST /environment/approve",
|
|
38
|
+
"POST /environment/runtime-install",
|
|
39
|
+
"POST /environment/remove",
|
|
40
|
+
"POST /engines/channel",
|
|
41
|
+
"POST /engines/update",
|
|
42
|
+
"POST /engines/revert",
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
// Secrets and connections share the Secrets page. Asking a named person to release one (`secrets.request`) stays
|
|
46
|
+
// open: it changes nothing until that person says yes.
|
|
47
|
+
secrets: {
|
|
48
|
+
changes: [
|
|
49
|
+
"secrets.set",
|
|
50
|
+
"secrets.generate",
|
|
51
|
+
"secrets.remove",
|
|
52
|
+
"secrets.setGate",
|
|
53
|
+
"secrets.removeGate",
|
|
54
|
+
"secrets.setHosts",
|
|
55
|
+
"secrets.setPolicy",
|
|
56
|
+
"capabilities.add",
|
|
57
|
+
"capabilities.remove",
|
|
58
|
+
"capabilities.rename",
|
|
59
|
+
"capabilities.setSecret",
|
|
60
|
+
"capabilities.sshKey",
|
|
61
|
+
"capabilities.login",
|
|
62
|
+
],
|
|
63
|
+
reveals: ["secrets.reveal"],
|
|
64
|
+
},
|
|
65
|
+
"data-protection": { changes: ["compliance.applyPreset"] },
|
|
66
|
+
extensions: {
|
|
67
|
+
changes: [
|
|
68
|
+
"extensions.create",
|
|
69
|
+
"extensions.remove",
|
|
70
|
+
"extensions.setSettings",
|
|
71
|
+
"extensions.approve",
|
|
72
|
+
"extensions.setEnabled",
|
|
73
|
+
"extensions.applyUpdate",
|
|
74
|
+
"extensions.revert",
|
|
75
|
+
"extensions.setUpdatePolicy",
|
|
76
|
+
"extensions.devSet",
|
|
77
|
+
"extensions.devClear",
|
|
78
|
+
"extensions.devReload",
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
// Leaving (`DELETE /members/self`) stays open: nobody can be held in a sandbox by a policy.
|
|
82
|
+
access: { changes: ["POST /members", "DELETE /members"] },
|
|
83
|
+
areas: { changes: ["areas.save", "areas.remove"] },
|
|
84
|
+
personas: { changes: ["personas.save", "personas.remove", "personas.savePrompt", "personas.saveSkill", "personas.removeSkill"] },
|
|
85
|
+
updates: { changes: ["system.autoUpdate", "system.skipUpdate"] },
|
|
86
|
+
} as const satisfies Readonly<Record<OrgSection, SectionRoutes>>;
|
|
87
|
+
|
|
88
|
+
// Refused on ANY bound sandbox, whatever its sections say: acts by which the sandbox's owner would take the organisation's
|
|
89
|
+
// sandbox away from everyone else in it. Closing the daemon to every browser (what deleting one's account does to each
|
|
90
|
+
// owned sandbox) would leave a department's work unreachable by the colleagues who carry it on; the organisation
|
|
91
|
+
// releases the sandbox first if that is what it wants. Listed under `access`, the part of the sandbox it belongs to.
|
|
92
|
+
export const ORG_BOUND_ROUTES: readonly string[] = ["POST /system/access/disable"];
|
|
93
|
+
|
|
94
|
+
// Route name → the section it belongs to and whether it is a change or a reveal; built once.
|
|
95
|
+
type Covered = { readonly section: OrgSection; readonly kind: "change" | "reveal" | "bound" };
|
|
96
|
+
const COVERED: ReadonlyMap<string, Covered> = (() => {
|
|
97
|
+
const covered = new Map<string, Covered>();
|
|
98
|
+
for (const section of ORG_SECTIONS) {
|
|
99
|
+
const routes: SectionRoutes = ORG_SECTION_ROUTES[section];
|
|
100
|
+
for (const name of routes.changes) {
|
|
101
|
+
covered.set(name, { section, kind: "change" });
|
|
102
|
+
}
|
|
103
|
+
for (const name of routes.reveals ?? []) {
|
|
104
|
+
covered.set(name, { section, kind: "reveal" });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const name of ORG_BOUND_ROUTES) {
|
|
108
|
+
covered.set(name, { section: "access", kind: "bound" });
|
|
109
|
+
}
|
|
110
|
+
return covered;
|
|
111
|
+
})();
|
|
112
|
+
|
|
113
|
+
// The section a route belongs to, or undefined for one no lock covers.
|
|
114
|
+
export const orgSectionOfRoute = (routeName: string): OrgSection | undefined => COVERED.get(routeName)?.section;
|
|
115
|
+
|
|
116
|
+
export interface OrgLockRefusal {
|
|
117
|
+
readonly error: string;
|
|
118
|
+
readonly reason: "org-locked";
|
|
119
|
+
readonly section: OrgSection;
|
|
120
|
+
readonly org: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The refusal for one request, or undefined to let it through. No policy (a personal sandbox) is always undefined:
|
|
124
|
+
// this function is the whole of what an organisation changes about a request, and for a personal sandbox it is nothing.
|
|
125
|
+
export const orgLockRefusal = (policy: OrgPolicy | undefined, routeName: string | undefined): OrgLockRefusal | undefined => {
|
|
126
|
+
if (policy === undefined || routeName === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const covered = COVERED.get(routeName);
|
|
130
|
+
if (covered === undefined) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const mode = orgSectionMode(policy, covered.section);
|
|
134
|
+
const refused = covered.kind === "bound" || (covered.kind === "change" ? mode !== "open" : mode === "hidden");
|
|
135
|
+
return refused
|
|
136
|
+
? {
|
|
137
|
+
error: `${policy.orgName} manages this part of the sandbox (${covered.section}); ask your organisation's admin to change it`,
|
|
138
|
+
reason: "org-locked",
|
|
139
|
+
section: covered.section,
|
|
140
|
+
org: policy.orgName,
|
|
141
|
+
}
|
|
142
|
+
: undefined;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// A write refused because it would undo an organisation's pinned settings (schemas/organisation.ts `preset`): the
|
|
146
|
+
// preset items it would have loosened, by the ids the data protection page shows them under.
|
|
147
|
+
export interface OrgPinRefusal {
|
|
148
|
+
readonly error: string;
|
|
149
|
+
readonly reason: "org-pinned";
|
|
150
|
+
readonly items: readonly string[];
|
|
151
|
+
readonly org: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const orgPinRefusal = (policy: Pick<OrgPolicy, "orgName">, items: readonly string[]): OrgPinRefusal => ({
|
|
155
|
+
error: `${policy.orgName} requires ${items.join(", ")} to stay as it set them; ask your organisation's admin to change it`,
|
|
156
|
+
reason: "org-pinned",
|
|
157
|
+
items,
|
|
158
|
+
org: policy.orgName,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// A grant refused because the organisation keeps its sandboxes to its own members (schemas/organisation.ts
|
|
162
|
+
// `membersOnly`).
|
|
163
|
+
export interface OrgMembersOnlyRefusal {
|
|
164
|
+
readonly error: string;
|
|
165
|
+
readonly reason: "org-members-only";
|
|
166
|
+
readonly org: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const orgMembersOnlyRefusal = (policy: Pick<OrgPolicy, "orgName">): OrgMembersOnlyRefusal => ({
|
|
170
|
+
error: `${policy.orgName} gives access to its sandboxes only to its own members; ask your organisation's admin to add this person first`,
|
|
171
|
+
reason: "org-members-only",
|
|
172
|
+
org: policy.orgName,
|
|
173
|
+
});
|
|
@@ -41,11 +41,12 @@ export const CONTROL_MCP_SERVERS: ReadonlySet<string> = new Set(
|
|
|
41
41
|
);
|
|
42
42
|
|
|
43
43
|
// Capability kinds whose id becomes an MCP server name for the turn, and so whose id must not collide with a daemon
|
|
44
|
-
// server: `mcp`, a core kind that is its own endpoint,
|
|
44
|
+
// server: `mcp`, a core kind that is its own endpoint, `link`, whose card is mounted as a server named after it, and
|
|
45
|
+
// every contributable kind the manifest schema says mints one
|
|
45
46
|
// (a device's and a connected browser's peer bridge, a cli card that serves tools), read off the schema's own meaning
|
|
46
47
|
// (`contributedServerMintingKinds`), so a kind that starts minting is refused here without a second list. Other kinds
|
|
47
48
|
// mint accounts, providers or infrastructure, never a `mcp__<id>__` server keyed by the id.
|
|
48
|
-
export const MCP_SERVER_MINTING_KINDS: ReadonlySet<string> = new Set(["mcp", ...contributedServerMintingKinds()]);
|
|
49
|
+
export const MCP_SERVER_MINTING_KINDS: ReadonlySet<string> = new Set(["mcp", "link", ...contributedServerMintingKinds()]);
|
|
49
50
|
|
|
50
51
|
// Whether adding or renaming a capability of this kind to this id would shadow a daemon server. The one predicate the
|
|
51
52
|
// capability routes consult, so the refusal and this list can't drift.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { RAW_ROUTE_LIST } from "../../protocol/raw/raw-routes.js";
|
|
2
|
+
import { SANDBOX_ROUTES, sandboxRouteFor } from "../../index.js";
|
|
3
|
+
import { type OrgPolicy, OrgPolicySchema, orgPinnedGroups, orgUnknownPins } from "../../schemas/organisation.js";
|
|
4
|
+
import { ORG_BOUND_ROUTES, ORG_SECTION_ROUTES, orgLockRefusal, orgPinRefusal, orgSectionOfRoute } from "../org-locks.js";
|
|
5
|
+
|
|
6
|
+
const policy = (sections: OrgPolicy["sections"]): OrgPolicy => ({ orgId: "org-1", orgName: "Urząd Gminy", version: 3, sections });
|
|
7
|
+
|
|
8
|
+
describe("org locks", () => {
|
|
9
|
+
it("names only routes that exist, and never a read as a change", () => {
|
|
10
|
+
const known = new Map([...SANDBOX_ROUTES, ...RAW_ROUTE_LIST].map((route) => [route.name, route.method]));
|
|
11
|
+
for (const [section, routes] of Object.entries(ORG_SECTION_ROUTES)) {
|
|
12
|
+
for (const name of routes.changes) {
|
|
13
|
+
expect({ section, name, method: known.get(name) }).toEqual({ section, name, method: expect.not.stringMatching(/^(GET|HEAD)$/) });
|
|
14
|
+
}
|
|
15
|
+
for (const name of "reveals" in routes ? routes.reveals : []) {
|
|
16
|
+
expect({ section, name, known: known.has(name) }).toEqual({ section, name, known: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("lets everything through for a personal sandbox", () => {
|
|
22
|
+
expect(orgLockRefusal(undefined, "settings.set")).toBeUndefined();
|
|
23
|
+
expect(orgLockRefusal(undefined, "secrets.reveal")).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("refuses a change to a locked section and names the organisation", () => {
|
|
27
|
+
const refused = orgLockRefusal(policy({ agent: "locked" }), "settings.set");
|
|
28
|
+
expect(refused).toMatchObject({ reason: "org-locked", section: "agent", org: "Urząd Gminy" });
|
|
29
|
+
expect(orgLockRefusal(policy({ agent: "locked" }), "secrets.set")).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps reveals open while locked and refuses them once hidden", () => {
|
|
33
|
+
expect(orgLockRefusal(policy({ secrets: "locked" }), "secrets.reveal")).toBeUndefined();
|
|
34
|
+
expect(orgLockRefusal(policy({ secrets: "locked" }), "secrets.set")).toMatchObject({ section: "secrets" });
|
|
35
|
+
expect(orgLockRefusal(policy({ secrets: "hidden" }), "secrets.reveal")).toMatchObject({ section: "secrets" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("leaves a person's own exits open whatever the policy", () => {
|
|
39
|
+
const everything = policy({ access: "hidden", agent: "hidden" });
|
|
40
|
+
expect(orgLockRefusal(everything, "DELETE /members/self")).toBeUndefined();
|
|
41
|
+
expect(orgLockRefusal(everything, "settings.confirmAiLiteracy")).toBeUndefined();
|
|
42
|
+
expect(orgLockRefusal(everything, "secrets.request")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("resolves a concrete request to its section through the route table", () => {
|
|
46
|
+
expect(orgSectionOfRoute(sandboxRouteFor("POST", "/members")?.name ?? "")).toBe("access");
|
|
47
|
+
expect(orgSectionOfRoute(sandboxRouteFor("DELETE", "/members/self")?.name ?? "")).toBeUndefined();
|
|
48
|
+
expect(orgSectionOfRoute(sandboxRouteFor("PUT", "/secrets/gates/OPENAI_KEY")?.name ?? "")).toBe("secrets");
|
|
49
|
+
expect(orgSectionOfRoute(sandboxRouteFor("POST", "/system/update/auto")?.name ?? "")).toBe("updates");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("names only bound-only routes that exist and change something", () => {
|
|
53
|
+
const known = new Map([...SANDBOX_ROUTES, ...RAW_ROUTE_LIST].map((route) => [route.name, route.method]));
|
|
54
|
+
for (const name of ORG_BOUND_ROUTES) {
|
|
55
|
+
expect({ name, method: known.get(name) }).toEqual({ name, method: expect.not.stringMatching(/^(GET|HEAD)$/) });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("refuses closing the sandbox to every browser on any bound sandbox, an all-open policy included", () => {
|
|
60
|
+
expect(orgLockRefusal(policy({}), "POST /system/access/disable")).toMatchObject({ reason: "org-locked", section: "access", org: "Urząd Gminy" });
|
|
61
|
+
expect(orgLockRefusal(policy({ access: "open" }), "POST /system/access/disable")).toMatchObject({ section: "access" });
|
|
62
|
+
expect(orgLockRefusal(undefined, "POST /system/access/disable")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reads pinned preset groups it knows in the contract's order, and reports the ones it does not", () => {
|
|
66
|
+
const pinned = OrgPolicySchema.parse({ orgId: "o", orgName: "O", version: 1, preset: ["judge", "telepathy", "privacy"] });
|
|
67
|
+
expect(orgPinnedGroups(pinned)).toEqual(["privacy", "judge"]);
|
|
68
|
+
expect(orgUnknownPins(pinned)).toEqual(["telepathy"]);
|
|
69
|
+
expect(orgPinnedGroups(OrgPolicySchema.parse({ orgId: "o", orgName: "O", version: 1 }))).toEqual([]);
|
|
70
|
+
expect(orgPinnedGroups(undefined)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("names the organisation and the items a refused pinned write would have loosened", () => {
|
|
74
|
+
expect(orgPinRefusal({ orgName: "Urząd Gminy" }, ["privacy.mode", "retention.transcriptDays"])).toEqual({
|
|
75
|
+
error: "Urząd Gminy requires privacy.mode, retention.transcriptDays to stay as it set them; ask your organisation's admin to change it",
|
|
76
|
+
reason: "org-pinned",
|
|
77
|
+
items: ["privacy.mode", "retention.transcriptDays"],
|
|
78
|
+
org: "Urząd Gminy",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("takes the people list only as sha256 digests", () => {
|
|
83
|
+
const base = { orgId: "o", orgName: "O", version: 1, membersOnly: true };
|
|
84
|
+
expect(OrgPolicySchema.safeParse({ ...base, people: ["a".repeat(64)] }).success).toBe(true);
|
|
85
|
+
expect(OrgPolicySchema.safeParse({ ...base, people: ["clerk@gmina.test"] }).success).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reads a section a policy leaves out as open, and refuses an unknown mode", () => {
|
|
89
|
+
expect(OrgPolicySchema.parse({ orgId: "o", orgName: "O", version: 1 }).sections).toEqual({});
|
|
90
|
+
expect(OrgPolicySchema.safeParse({ orgId: "o", orgName: "O", version: 1, sections: { agent: "sealed" } }).success).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -25,9 +25,10 @@ describe("reserved MCP server names", () => {
|
|
|
25
25
|
|
|
26
26
|
describe("collidesWithReservedServer", () => {
|
|
27
27
|
// Derived from the manifest schema's meanings: a cli card mints a server when it serves tools, a device, a
|
|
28
|
-
// connected browser and a connected phone always do,
|
|
28
|
+
// connected browser and a connected phone always do, `mcp` is the core kind that is its own endpoint, and a `link`
|
|
29
|
+
// card is mounted as a server named after it.
|
|
29
30
|
test("the minting kinds are exactly the ones whose id becomes a server name", () => {
|
|
30
|
-
expect([...MCP_SERVER_MINTING_KINDS].toSorted()).toEqual(["cli", "device", "mcp", "phone", "webext"]);
|
|
31
|
+
expect([...MCP_SERVER_MINTING_KINDS].toSorted()).toEqual(["cli", "device", "link", "mcp", "phone", "webext"]);
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
test("a kind whose id becomes a server name may not reuse a reserved one", () => {
|
|
@@ -4,7 +4,7 @@ import { ActivityStatusSchema } from "../../schemas/activity.js";
|
|
|
4
4
|
import { rawRouteUrl } from "../raw/raw-routes.js";
|
|
5
5
|
|
|
6
6
|
// Wire between the daemon and an extension's realtime-listener gateway (ext-discord, ext-slack, ext-telegram,
|
|
7
|
-
// ext-whatsapp, ext-imap, ext-google-workspace): the five /listeners/:provider routes (state, dispatch, failure,
|
|
7
|
+
// ext-whatsapp, ext-imap, ext-google-workspace, ext-microsoft-365): the five /listeners/:provider routes (state, dispatch, failure,
|
|
8
8
|
// status, stop). Shared here so both ends compile against one declaration.
|
|
9
9
|
|
|
10
10
|
// The path of one of the five listener routes for a provider, built from RAW_ROUTES so the gateway and the daemon
|
|
@@ -18,6 +18,19 @@ export const webextSessionUrl = (sandboxUrl: string): string => `${sandboxUrl.re
|
|
|
18
18
|
// an MCP result.
|
|
19
19
|
export const webextLendUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/webext/lend`;
|
|
20
20
|
|
|
21
|
+
// Where the extension collects a stored secret it was handed a ticket to type. Its own door for the reason the two above
|
|
22
|
+
// are: a tool answer over the socket lands in the model's context, and this value must never reach one.
|
|
23
|
+
export const webextSecretUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/webext/secret`;
|
|
24
|
+
|
|
25
|
+
// The tool the daemon calls on a browser to type a secret into its focused field, taking only a ticket the daemon
|
|
26
|
+
// minted for that one call. The agent's own mount of the browser never lists it (webext-peer.ts): a model holding the
|
|
27
|
+
// tool would hold nothing it could use, so it is not offered.
|
|
28
|
+
export const WEBEXT_TICKET_TOOL = "type_ticket";
|
|
29
|
+
|
|
30
|
+
// How long a ticket waits for its browser to collect it. Both sides read it: the extension's own question to the person
|
|
31
|
+
// has to be answered inside it, or the ticket it then presents has already lapsed.
|
|
32
|
+
export const WEBEXT_TICKET_TTL_MS = 60_000;
|
|
33
|
+
|
|
21
34
|
// One pasteable code, not two fields: base64url of {url, token}, since a popup gets one paste, not two boxes. `token`
|
|
22
35
|
// is single-use and expires in ten minutes; the prefix versions the format.
|
|
23
36
|
const PAIRING_PREFIX = "ixb1_";
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
],
|
|
47
47
|
"bulk": [
|
|
48
48
|
"GET /diff/raw",
|
|
49
|
+
"GET /agents/{id}/documents/raw",
|
|
49
50
|
"GET /workspace/raw",
|
|
50
51
|
"GET /workspace/media",
|
|
51
52
|
"GET /workspace/download",
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
"lanes": [
|
|
62
63
|
{ "host": "sandbox-abcdef012345.sbx.example.test", "method": "GET", "path": "/workspace/media?path=a.mp4", "lane": "bulk" },
|
|
63
64
|
{ "host": "sandbox-abcdef012345.sbx.example.test", "method": "GET", "path": "/workspace/download?ticket=t", "lane": "bulk" },
|
|
65
|
+
{ "host": "sandbox-abcdef012345.sbx.example.test", "method": "GET", "path": "/agents/c1/documents/raw?path=letter.odt", "lane": "bulk" },
|
|
64
66
|
{ "host": "sandbox-abcdef012345.sbx.example.test", "method": "POST", "path": "/workspace/upload?path=a", "lane": "bulk" },
|
|
65
67
|
{ "host": "sandbox-abcdef012345.sbx.example.test:443", "method": "GET", "path": "/system/runners/git/app/info/refs?service=git-upload-pack", "lane": "bulk" },
|
|
66
68
|
{ "host": "sandbox-abcdef012345.sbx.example.test", "method": "POST", "path": "/system/runners/git/app/git-receive-pack", "lane": "bulk" },
|
|
@@ -68,7 +68,7 @@ export const redeemWhileWarming = async (
|
|
|
68
68
|
return { kind: "answered", response };
|
|
69
69
|
} catch (error) {
|
|
70
70
|
if (signal?.aborted === true) {
|
|
71
|
-
//
|
|
71
|
+
// the stop's own reason, as an aborted fetch rejects with it
|
|
72
72
|
throw signal.reason;
|
|
73
73
|
}
|
|
74
74
|
if (error instanceof Warming) {
|