switchroom 0.14.35 → 0.14.36

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.
@@ -11386,7 +11386,8 @@ var VaultConfigSchema = exports_external.object({
11386
11386
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
11387
11387
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
11388
11388
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
11389
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
11389
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
11390
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
11390
11391
  }).default({}).superRefine((broker, ctx) => {
11391
11392
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
11392
11393
  ctx.addIssue({
@@ -11386,7 +11386,8 @@ var VaultConfigSchema = exports_external.object({
11386
11386
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
11387
11387
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
11388
11388
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
11389
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
11389
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
11390
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
11390
11391
  }).default({}).superRefine((broker, ctx) => {
11391
11392
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
11392
11393
  ctx.addIssue({
@@ -12134,7 +12134,8 @@ var VaultConfigSchema = exports_external.object({
12134
12134
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault \u2014 no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
12135
12135
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
12136
12136
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve \u2014 two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt \u2014 single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
12137
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
12137
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
12138
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation \u2014 granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` \u2014 no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
12138
12139
  }).default({}).superRefine((broker, ctx) => {
12139
12140
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
12140
12141
  ctx.addIssue({
@@ -13950,7 +13950,8 @@ var init_schema = __esm(() => {
13950
13950
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault \u2014 no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
13951
13951
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
13952
13952
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve \u2014 two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt \u2014 single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
13953
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
13953
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
13954
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation \u2014 granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` \u2014 no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
13954
13955
  }).default({}).superRefine((broker, ctx) => {
13955
13956
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
13956
13957
  ctx.addIssue({
@@ -49439,8 +49440,8 @@ var {
49439
49440
  } = import__.default;
49440
49441
 
49441
49442
  // src/build-info.ts
49442
- var VERSION = "0.14.35";
49443
- var COMMIT_SHA = "7ac06aea";
49443
+ var VERSION = "0.14.36";
49444
+ var COMMIT_SHA = "127b6f28";
49444
49445
 
49445
49446
  // src/cli/agent.ts
49446
49447
  init_source();
@@ -61321,6 +61322,31 @@ function listGrants(db, agent_slug) {
61321
61322
  return rows.map(rowToGrant);
61322
61323
  }
61323
61324
 
61325
+ // src/vault/admin-only-keys.ts
61326
+ function globToRegExp(pattern) {
61327
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
61328
+ return new RegExp(`^${escaped}$`);
61329
+ }
61330
+ function matchesAdminOnlyKey(key, patterns) {
61331
+ for (const p of patterns) {
61332
+ if (p === key)
61333
+ return true;
61334
+ if (p.includes("*") && globToRegExp(p).test(key))
61335
+ return true;
61336
+ }
61337
+ return false;
61338
+ }
61339
+ function adminOnlyKeysBeingAdded(requestedKeys, existingKeys, patterns) {
61340
+ const existing = new Set(existingKeys);
61341
+ const blocked = [];
61342
+ for (const k of requestedKeys) {
61343
+ if (matchesAdminOnlyKey(k, patterns) && !existing.has(k) && !blocked.includes(k)) {
61344
+ blocked.push(k);
61345
+ }
61346
+ }
61347
+ return blocked;
61348
+ }
61349
+
61324
61350
  // src/vault/grants-db.ts
61325
61351
  import * as os3 from "node:os";
61326
61352
  import * as path2 from "node:path";
@@ -62699,6 +62725,30 @@ class VaultBroker {
62699
62725
  socket.write(encodeResponse(errorResponse("DENIED", `posture-attested list refused: request.agent=${reqAgent} but calling peer is ${agentName}`)));
62700
62726
  return;
62701
62727
  }
62728
+ if (req.op === "mint_grant") {
62729
+ const adminOnly = this.config?.vault?.broker?.adminOnlyKeys ?? [];
62730
+ if (adminOnly.length > 0) {
62731
+ const requested = [
62732
+ ...req.keys ?? [],
62733
+ ...req.write_keys ?? []
62734
+ ];
62735
+ const nowSec = Math.floor(Date.now() / 1000);
62736
+ const existingKeys = listGrants(this.grantsDb, agentName).filter((g) => g.expires_at === null || g.expires_at > nowSec).flatMap((g) => [...g.key_allow ?? [], ...g.write_allow ?? []]);
62737
+ const blocked = adminOnlyKeysBeingAdded(requested, existingKeys, adminOnly);
62738
+ if (blocked.length > 0) {
62739
+ writeAudit({
62740
+ ts: new Date().toISOString(),
62741
+ op: req.op,
62742
+ caller: auditCaller,
62743
+ pid: auditPid,
62744
+ cgroup: auditCgroup,
62745
+ result: "denied:posture-mint-admin-only-key"
62746
+ });
62747
+ socket.write(encodeResponse(errorResponse("DENIED", `admin-only credential(s) ${blocked.join(", ")} cannot be minted via posture ` + `attestation \u2014 they require the operator passphrase (vault.broker.adminOnlyKeys)`)));
62748
+ return;
62749
+ }
62750
+ }
62751
+ }
62702
62752
  if (this.passphrase === null) {
62703
62753
  writeAudit({
62704
62754
  ts: new Date().toISOString(),
@@ -14121,7 +14121,8 @@ var VaultConfigSchema = exports_external.object({
14121
14121
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
14122
14122
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
14123
14123
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
14124
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
14124
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
14125
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
14125
14126
  }).default({}).superRefine((broker, ctx) => {
14126
14127
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
14127
14128
  ctx.addIssue({
@@ -11707,7 +11707,8 @@ var init_schema = __esm(() => {
11707
11707
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
11708
11708
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
11709
11709
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
11710
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
11710
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
11711
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
11711
11712
  }).default({}).superRefine((broker, ctx) => {
11712
11713
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
11713
11714
  ctx.addIssue({
@@ -11707,7 +11707,8 @@ var init_schema = __esm(() => {
11707
11707
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault — no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
11708
11708
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
11709
11709
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
11710
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
11710
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
11711
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
11711
11712
  }).default({}).superRefine((broker, ctx) => {
11712
11713
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
11713
11714
  ctx.addIssue({
@@ -15712,6 +15713,31 @@ function listGrants(db, agent_slug) {
15712
15713
  return rows.map(rowToGrant);
15713
15714
  }
15714
15715
 
15716
+ // src/vault/admin-only-keys.ts
15717
+ function globToRegExp(pattern) {
15718
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
15719
+ return new RegExp(`^${escaped}$`);
15720
+ }
15721
+ function matchesAdminOnlyKey(key, patterns) {
15722
+ for (const p of patterns) {
15723
+ if (p === key)
15724
+ return true;
15725
+ if (p.includes("*") && globToRegExp(p).test(key))
15726
+ return true;
15727
+ }
15728
+ return false;
15729
+ }
15730
+ function adminOnlyKeysBeingAdded(requestedKeys, existingKeys, patterns) {
15731
+ const existing = new Set(existingKeys);
15732
+ const blocked = [];
15733
+ for (const k of requestedKeys) {
15734
+ if (matchesAdminOnlyKey(k, patterns) && !existing.has(k) && !blocked.includes(k)) {
15735
+ blocked.push(k);
15736
+ }
15737
+ }
15738
+ return blocked;
15739
+ }
15740
+
15715
15741
  // src/vault/grants-db.ts
15716
15742
  import * as os2 from "node:os";
15717
15743
  import * as path2 from "node:path";
@@ -17090,6 +17116,30 @@ class VaultBroker {
17090
17116
  socket.write(encodeResponse(errorResponse("DENIED", `posture-attested list refused: request.agent=${reqAgent} but calling peer is ${agentName}`)));
17091
17117
  return;
17092
17118
  }
17119
+ if (req.op === "mint_grant") {
17120
+ const adminOnly = this.config?.vault?.broker?.adminOnlyKeys ?? [];
17121
+ if (adminOnly.length > 0) {
17122
+ const requested = [
17123
+ ...req.keys ?? [],
17124
+ ...req.write_keys ?? []
17125
+ ];
17126
+ const nowSec = Math.floor(Date.now() / 1000);
17127
+ const existingKeys = listGrants(this.grantsDb, agentName).filter((g) => g.expires_at === null || g.expires_at > nowSec).flatMap((g) => [...g.key_allow ?? [], ...g.write_allow ?? []]);
17128
+ const blocked = adminOnlyKeysBeingAdded(requested, existingKeys, adminOnly);
17129
+ if (blocked.length > 0) {
17130
+ writeAudit({
17131
+ ts: new Date().toISOString(),
17132
+ op: req.op,
17133
+ caller: auditCaller,
17134
+ pid: auditPid,
17135
+ cgroup: auditCgroup,
17136
+ result: "denied:posture-mint-admin-only-key"
17137
+ });
17138
+ socket.write(encodeResponse(errorResponse("DENIED", `admin-only credential(s) ${blocked.join(", ")} cannot be minted via posture ` + `attestation — they require the operator passphrase (vault.broker.adminOnlyKeys)`)));
17139
+ return;
17140
+ }
17141
+ }
17142
+ }
17093
17143
  if (this.passphrase === null) {
17094
17144
  writeAudit({
17095
17145
  ts: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.35",
3
+ "version": "0.14.36",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "pretest": "npm run build",
26
26
  "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
27
27
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
28
+ "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
29
29
  "test:watch": "vitest",
30
30
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
@@ -24092,7 +24092,8 @@ var init_schema = __esm(() => {
24092
24092
  autoUnlock: exports_external.boolean().default(false).describe("Auto-unlock the vault at broker start using a machine-bound " + "encrypted blob. Off by default. When enabled, the broker reads " + "the configured blob path, derives the AES key from /etc/machine-id, " + "decrypts the passphrase, and unlocks the vault \u2014 no sudo, no " + "systemd-creds, no TPM. Run `switchroom vault broker " + "enable-auto-unlock` once to write the blob."),
24093
24093
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
24094
24094
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve \u2014 two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt \u2014 single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
24095
- postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config.")
24095
+ postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` \u2014 no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored \u2014 passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
24096
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation \u2014 granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` \u2014 no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
24096
24097
  }).default({}).superRefine((broker, ctx) => {
24097
24098
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
24098
24099
  ctx.addIssue({
@@ -51781,10 +51782,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51781
51782
  }
51782
51783
 
51783
51784
  // ../src/build-info.ts
51784
- var VERSION = "0.14.35";
51785
- var COMMIT_SHA = "7ac06aea";
51786
- var COMMIT_DATE = "2026-06-01T21:48:46Z";
51787
- var LATEST_PR = 2072;
51785
+ var VERSION = "0.14.36";
51786
+ var COMMIT_SHA = "127b6f28";
51787
+ var COMMIT_DATE = "2026-06-01T23:38:45Z";
51788
+ var LATEST_PR = 2074;
51788
51789
  var COMMITS_AHEAD_OF_TAG = 0;
51789
51790
 
51790
51791
  // gateway/boot-version.ts
@@ -52083,6 +52084,21 @@ function resolveVaultApprovalPosture(broker) {
52083
52084
  return { mode: "passphrase" };
52084
52085
  }
52085
52086
 
52087
+ // ../src/vault/admin-only-keys.ts
52088
+ function globToRegExp(pattern) {
52089
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
52090
+ return new RegExp(`^${escaped}$`);
52091
+ }
52092
+ function matchesAdminOnlyKey(key, patterns) {
52093
+ for (const p of patterns) {
52094
+ if (p === key)
52095
+ return true;
52096
+ if (p.includes("*") && globToRegExp(p).test(key))
52097
+ return true;
52098
+ }
52099
+ return false;
52100
+ }
52101
+
52086
52102
  // registry/turns-schema.ts
52087
52103
  import { chmodSync as chmodSync4, mkdirSync as mkdirSync22 } from "fs";
52088
52104
  import { join as join33 } from "path";
@@ -53495,6 +53511,7 @@ function rememberAgentButtonMeta(chatId, messageId, meta) {
53495
53511
  var vaultPassphraseCache = new Map;
53496
53512
  var VAULT_PASSPHRASE_TTL_MS = 1800000;
53497
53513
  var VAULT_APPROVAL_AUTH_MODE = "passphrase";
53514
+ var ADMIN_ONLY_KEYS = [];
53498
53515
  function initVaultApprovalPosture() {
53499
53516
  let cfg;
53500
53517
  try {
@@ -53508,6 +53525,11 @@ function initVaultApprovalPosture() {
53508
53525
  VAULT_APPROVAL_AUTH_MODE = resolved.mode;
53509
53526
  if (resolved.mode === "telegram-id") {
53510
53527
  process.stderr.write(`telegram gateway: vault approval posture = telegram-id ` + `(single-factor \u2014 broker mediates attestation via attest_via_posture)
53528
+ `);
53529
+ }
53530
+ ADMIN_ONLY_KEYS = cfg.vault?.broker?.adminOnlyKeys ?? [];
53531
+ if (ADMIN_ONLY_KEYS.length > 0) {
53532
+ process.stderr.write(`telegram gateway: ${ADMIN_ONLY_KEYS.length} admin-only vault key pattern(s) ` + `\u2014 grants approved by allowFrom[0] + operator passphrase only
53511
53533
  `);
53512
53534
  }
53513
53535
  }
@@ -59724,7 +59746,14 @@ async function handleVaultRequestAccessCallback(ctx, data) {
59724
59746
  return;
59725
59747
  }
59726
59748
  if (action === "approve") {
59727
- if (VAULT_APPROVAL_AUTH_MODE === "telegram-id") {
59749
+ const isAdminOnly = matchesAdminOnlyKey(pending2.key, ADMIN_ONLY_KEYS);
59750
+ if (isAdminOnly && senderId !== access.allowFrom[0]) {
59751
+ await ctx.answerCallbackQuery({
59752
+ text: "\uD83D\uDD12 Admin-only credential \u2014 only the owner can approve this."
59753
+ }).catch(() => {});
59754
+ return;
59755
+ }
59756
+ if (!isAdminOnly && VAULT_APPROVAL_AUTH_MODE === "telegram-id") {
59728
59757
  const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`;
59729
59758
  if (pending2.card_message_id != null) {
59730
59759
  await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, `\u2705 Approved by @${escapeHtmlForTg(username)} \u2014 minting\u2026`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
@@ -59754,7 +59783,9 @@ async function handleVaultRequestAccessCallback(ctx, data) {
59754
59783
  });
59755
59784
  const joiningBatch = items.length > 1;
59756
59785
  await ctx.answerCallbackQuery({ text: joiningBatch ? `\uD83D\uDD10 Queued \u2014 one passphrase covers ${items.length} cards` : "\uD83D\uDD10 Send your passphrase\u2026" }).catch(() => {});
59757
- await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, joiningBatch ? `\uD83D\uDD10 <b>Queued behind an earlier card.</b> Type your passphrase as your next message \u2014 it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).` : `\uD83D\uDD10 <b>Vault is locked.</b> Reply with your passphrase as your next message \u2014 we'll unlock, mint the grant for <b>${escapeHtmlForTg(pending2.agent)}</b>, and delete the passphrase message in one step.
59786
+ await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, joiningBatch ? `\uD83D\uDD10 <b>Queued behind an earlier card.</b> Type your passphrase as your next message \u2014 it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).` : isAdminOnly ? `\uD83D\uDD12 <b>Admin-only credential.</b> <code>${escapeHtmlForTg(pending2.key)}</code> requires your vault passphrase to grant \u2014 reply with it as your next message and we'll mint the grant for <b>${escapeHtmlForTg(pending2.agent)}</b>, then delete the passphrase message.
59787
+
59788
+ <i>The passphrase is what proves it's you: an agent can never mint this key on its own.</i>` : `\uD83D\uDD10 <b>Vault is locked.</b> Reply with your passphrase as your next message \u2014 we'll unlock, mint the grant for <b>${escapeHtmlForTg(pending2.agent)}</b>, and delete the passphrase message in one step.
59758
59789
 
59759
59790
  <i>Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches.</i>`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
59760
59791
  return;
@@ -420,6 +420,7 @@ import {
420
420
  approvalRecord,
421
421
  } from '../../src/vault/approvals/client.js'
422
422
  import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
423
+ import { matchesAdminOnlyKey } from '../../src/vault/admin-only-keys.js'
423
424
  import {
424
425
  openTurnsDb,
425
426
  markOrphanedWithTimeoutClassification,
@@ -2690,6 +2691,15 @@ const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
2690
2691
  */
2691
2692
  let VAULT_APPROVAL_AUTH_MODE: 'passphrase' | 'telegram-id' = 'passphrase'
2692
2693
 
2694
+ /**
2695
+ * Admin-only vault keys (`vault.broker.adminOnlyKeys`). A grant for one
2696
+ * of these may be approved ONLY by the admin operator
2697
+ * (`access.allowFrom[0]`) and is always minted via the operator
2698
+ * passphrase — never posture, even under telegram-id mode (the broker
2699
+ * enforces the same rule). Cached at boot alongside the posture mode.
2700
+ */
2701
+ let ADMIN_ONLY_KEYS: string[] = []
2702
+
2693
2703
  export function initVaultApprovalPosture(): void {
2694
2704
  let cfg: ReturnType<typeof loadSwitchroomConfig>
2695
2705
  try {
@@ -2722,6 +2732,13 @@ export function initVaultApprovalPosture(): void {
2722
2732
  `(single-factor — broker mediates attestation via attest_via_posture)\n`,
2723
2733
  )
2724
2734
  }
2735
+ ADMIN_ONLY_KEYS = cfg.vault?.broker?.adminOnlyKeys ?? []
2736
+ if (ADMIN_ONLY_KEYS.length > 0) {
2737
+ process.stderr.write(
2738
+ `telegram gateway: ${ADMIN_ONLY_KEYS.length} admin-only vault key pattern(s) ` +
2739
+ `— grants approved by allowFrom[0] + operator passphrase only\n`,
2740
+ )
2741
+ }
2725
2742
  }
2726
2743
  /**
2727
2744
  * Gateway-side guard on vault-key shape — UX gate, not a security
@@ -13851,11 +13868,31 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
13851
13868
  }
13852
13869
 
13853
13870
  if (action === 'approve') {
13871
+ // Admin-only credentials (`vault.broker.adminOnlyKeys`) are held to a
13872
+ // higher bar: ONLY the admin operator (allowFrom[0]) may approve, and
13873
+ // the grant must be minted with the operator passphrase — never
13874
+ // posture, even under telegram-id mode (the broker enforces the same
13875
+ // rule, so a posture mint would just be rejected). So for an
13876
+ // admin-only key we (a) reject taps from any non-admin allowFrom
13877
+ // member, and (b) skip the telegram-id posture branch below, falling
13878
+ // through to the passphrase-prompt path. The card + buttons stay
13879
+ // intact on a non-admin tap so the admin can still approve.
13880
+ const isAdminOnly = matchesAdminOnlyKey(pending.key, ADMIN_ONLY_KEYS)
13881
+ if (isAdminOnly && senderId !== access.allowFrom[0]) {
13882
+ await ctx
13883
+ .answerCallbackQuery({
13884
+ text: '🔒 Admin-only credential — only the owner can approve this.',
13885
+ })
13886
+ .catch(() => {})
13887
+ return
13888
+ }
13889
+
13854
13890
  // Posture: telegram-id (opt-in single-factor). The broker is
13855
13891
  // auto-unlocked and we silently hold the passphrase in memory; skip
13856
13892
  // the passphrase-cache lookup + prompt entirely and mint directly.
13857
13893
  // Allowlist check above already attested the operator's Telegram ID.
13858
- if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
13894
+ // Admin-only keys are excluded — they take the passphrase path below.
13895
+ if (!isAdminOnly && VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
13859
13896
  const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`
13860
13897
  if (pending.card_message_id != null) {
13861
13898
  await ctx.api
@@ -13920,6 +13957,9 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
13920
13957
  pending.card_message_id,
13921
13958
  joiningBatch
13922
13959
  ? `🔐 <b>Queued behind an earlier card.</b> Type your passphrase as your next message — it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).`
13960
+ : isAdminOnly
13961
+ ? `🔒 <b>Admin-only credential.</b> <code>${escapeHtmlForTg(pending.key)}</code> requires your vault passphrase to grant — reply with it as your next message and we'll mint the grant for <b>${escapeHtmlForTg(pending.agent)}</b>, then delete the passphrase message.\n\n` +
13962
+ `<i>The passphrase is what proves it's you: an agent can never mint this key on its own.</i>`
13923
13963
  : `🔐 <b>Vault is locked.</b> Reply with your passphrase as your next message — we'll unlock, mint the grant for <b>${escapeHtmlForTg(pending.agent)}</b>, and delete the passphrase message in one step.\n\n` +
13924
13964
  `<i>Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches.</i>`,
13925
13965
  { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },