switchroom 0.14.12 → 0.14.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +13 -11
- package/dist/host-control/main.js +80 -6
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +61 -8
- package/telegram-plugin/dist/gateway/gateway.js +283 -161
- package/telegram-plugin/dist/server.js +64 -9
- package/telegram-plugin/gateway/gateway.ts +78 -66
- package/telegram-plugin/gateway/ipc-protocol.ts +4 -2
- package/telegram-plugin/permission-rule.ts +200 -122
- package/telegram-plugin/permission-title.ts +209 -197
- package/telegram-plugin/tests/always-allow-grant.test.ts +86 -54
- package/telegram-plugin/tests/always-allow-persist.test.ts +35 -34
- package/telegram-plugin/tests/permission-rule.test.ts +185 -127
- package/telegram-plugin/tests/permission-title.test.ts +109 -195
|
@@ -24045,6 +24045,23 @@ var init_ipc_client = () => {};
|
|
|
24045
24045
|
|
|
24046
24046
|
// permission-rule.ts
|
|
24047
24047
|
import { basename as basename2 } from "node:path";
|
|
24048
|
+
function resolveSkillName(input) {
|
|
24049
|
+
return readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
|
|
24050
|
+
}
|
|
24051
|
+
function filePathFrom(input) {
|
|
24052
|
+
if (!input)
|
|
24053
|
+
return null;
|
|
24054
|
+
return readString(input, "file_path") ?? readString(input, "notebook_path");
|
|
24055
|
+
}
|
|
24056
|
+
function bashFirstToken(command) {
|
|
24057
|
+
const m = /^\s*([^\s|&;<>()`$]+)/.exec(command);
|
|
24058
|
+
if (!m)
|
|
24059
|
+
return null;
|
|
24060
|
+
const tok = m[1];
|
|
24061
|
+
if (tok.includes(".."))
|
|
24062
|
+
return null;
|
|
24063
|
+
return /^[A-Za-z0-9._\-\/]+$/.test(tok) ? tok : null;
|
|
24064
|
+
}
|
|
24048
24065
|
function parseInput(raw) {
|
|
24049
24066
|
if (!raw || typeof raw !== "string")
|
|
24050
24067
|
return null;
|
|
@@ -24073,20 +24090,58 @@ function skillBasenameFromPath(input) {
|
|
|
24073
24090
|
function matchesAllowRule(rule, toolName, inputPreview) {
|
|
24074
24091
|
if (!rule || !toolName)
|
|
24075
24092
|
return false;
|
|
24076
|
-
|
|
24077
|
-
|
|
24078
|
-
|
|
24093
|
+
if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
|
|
24094
|
+
const prefix = rule.slice(0, -1);
|
|
24095
|
+
return toolName.startsWith(prefix);
|
|
24096
|
+
}
|
|
24097
|
+
const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
|
|
24098
|
+
if (scoped) {
|
|
24099
|
+
const ruleTool = scoped[1];
|
|
24100
|
+
const arg = scoped[2];
|
|
24101
|
+
if (ruleTool !== toolName)
|
|
24079
24102
|
return false;
|
|
24080
|
-
const ruleSkill = skillMatch[1];
|
|
24081
24103
|
const input = parseInput(inputPreview);
|
|
24082
|
-
if (
|
|
24083
|
-
|
|
24084
|
-
|
|
24085
|
-
|
|
24104
|
+
if (ruleTool === "Skill") {
|
|
24105
|
+
if (!input)
|
|
24106
|
+
return false;
|
|
24107
|
+
return resolveSkillName(input) === arg;
|
|
24108
|
+
}
|
|
24109
|
+
if (ruleTool === "Bash") {
|
|
24110
|
+
const cmd = input ? readString(input, "command") : null;
|
|
24111
|
+
if (!cmd)
|
|
24112
|
+
return false;
|
|
24113
|
+
const m = /^([^:]+):\*$/.exec(arg);
|
|
24114
|
+
if (!m)
|
|
24115
|
+
return false;
|
|
24116
|
+
return bashFirstToken(cmd) === m[1];
|
|
24117
|
+
}
|
|
24118
|
+
if (FILE_TOOLS.has(ruleTool)) {
|
|
24119
|
+
return filePathFrom(input) === arg;
|
|
24120
|
+
}
|
|
24121
|
+
return false;
|
|
24086
24122
|
}
|
|
24087
24123
|
return rule === toolName;
|
|
24088
24124
|
}
|
|
24089
|
-
var
|
|
24125
|
+
var FILE_TOOLS, BROAD_ONLY_TOOLS;
|
|
24126
|
+
var init_permission_rule = __esm(() => {
|
|
24127
|
+
FILE_TOOLS = new Set([
|
|
24128
|
+
"Edit",
|
|
24129
|
+
"Write",
|
|
24130
|
+
"MultiEdit",
|
|
24131
|
+
"NotebookEdit",
|
|
24132
|
+
"Read"
|
|
24133
|
+
]);
|
|
24134
|
+
BROAD_ONLY_TOOLS = new Set([
|
|
24135
|
+
"Glob",
|
|
24136
|
+
"Grep",
|
|
24137
|
+
"WebFetch",
|
|
24138
|
+
"WebSearch",
|
|
24139
|
+
"Task",
|
|
24140
|
+
"Agent",
|
|
24141
|
+
"TodoWrite",
|
|
24142
|
+
"ExitPlanMode"
|
|
24143
|
+
]);
|
|
24144
|
+
});
|
|
24090
24145
|
|
|
24091
24146
|
// bridge/bridge.ts
|
|
24092
24147
|
var exports_bridge = {};
|
|
@@ -361,8 +361,8 @@ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
|
|
|
361
361
|
import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
|
|
362
362
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
363
363
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
364
|
-
import {
|
|
365
|
-
import {
|
|
364
|
+
import { formatPermissionCardBody, describeGrant } from '../permission-title.js'
|
|
365
|
+
import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
|
|
366
366
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
367
367
|
import {
|
|
368
368
|
readClaudeJsonOverage,
|
|
@@ -3773,6 +3773,21 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
|
|
|
3773
3773
|
* pre-2747 TTL sweep can reference it; ipcServer/pendingPermissionBuffer
|
|
3774
3774
|
* are resolved at call-time, after module init.)
|
|
3775
3775
|
*/
|
|
3776
|
+
/**
|
|
3777
|
+
* The default permission-card action row: ❌ Deny · ✅ Allow once ·
|
|
3778
|
+
* 🔁 Always… (the last only when a meaningful always-rule exists).
|
|
3779
|
+
* Tapping "🔁 Always…" swaps this row for the scope sub-menu; "← Back"
|
|
3780
|
+
* rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
|
|
3781
|
+
* never approach Telegram's 64-byte ceiling.
|
|
3782
|
+
*/
|
|
3783
|
+
function buildPermissionActionRow(requestId: string, showAlways: boolean): InlineKeyboard {
|
|
3784
|
+
const kb = new InlineKeyboard()
|
|
3785
|
+
.text('❌ Deny', `perm:deny:${requestId}`)
|
|
3786
|
+
.text('✅ Allow once', `perm:allow:${requestId}`)
|
|
3787
|
+
if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
|
|
3788
|
+
return kb
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3776
3791
|
function dispatchPermissionVerdict(ev: PermissionEvent): void {
|
|
3777
3792
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
3778
3793
|
const delivered = ipcServer.sendToAgent(selfAgent, ev)
|
|
@@ -4106,37 +4121,25 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4106
4121
|
const { requestId, toolName, description, inputPreview } = msg
|
|
4107
4122
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
4108
4123
|
const access = loadAccess()
|
|
4109
|
-
//
|
|
4110
|
-
//
|
|
4111
|
-
//
|
|
4112
|
-
//
|
|
4113
|
-
//
|
|
4114
|
-
// collapsed-view fix only. Sent with parse_mode=HTML below.
|
|
4124
|
+
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
4125
|
+
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
4126
|
+
// The operator sees what is being requested and why at a glance.
|
|
4127
|
+
// Mirrors the `vault_request_access` card layout. Sent with
|
|
4128
|
+
// parse_mode=HTML below.
|
|
4115
4129
|
const text = formatPermissionCardBody({
|
|
4116
4130
|
toolName,
|
|
4117
4131
|
inputPreview,
|
|
4118
4132
|
description,
|
|
4119
4133
|
agentName: _client.agentName,
|
|
4120
4134
|
})
|
|
4121
|
-
//
|
|
4122
|
-
//
|
|
4123
|
-
//
|
|
4124
|
-
//
|
|
4125
|
-
//
|
|
4126
|
-
|
|
4127
|
-
const
|
|
4128
|
-
|
|
4129
|
-
.text('✅ Allow', `perm:allow:${requestId}`)
|
|
4130
|
-
.text('❌ Deny', `perm:deny:${requestId}`)
|
|
4131
|
-
if (alwaysRule != null) {
|
|
4132
|
-
// Second row — full-width label like "🔁 Always allow Skill(mail)"
|
|
4133
|
-
// so the operator sees exactly what rule they're whitelisting
|
|
4134
|
-
// before tapping. Truncate at Telegram's 64-byte callback_data
|
|
4135
|
-
// ceiling defensively (long MCP tool names can push past it).
|
|
4136
|
-
keyboard
|
|
4137
|
-
.row()
|
|
4138
|
-
.text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
|
|
4139
|
-
}
|
|
4135
|
+
// Compact action row: ❌ Deny · ✅ Allow once · 🔁 Always… — the
|
|
4136
|
+
// scope of an "always" grant stays hidden until the operator taps
|
|
4137
|
+
// "🔁 Always…", which swaps the row for a scope choice (this file /
|
|
4138
|
+
// any file ⚠️). The "🔁 Always…" button only appears when we can
|
|
4139
|
+
// synthesize a meaningful rule for this tool; unknown tools get the
|
|
4140
|
+
// two-button row only.
|
|
4141
|
+
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
4142
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
4140
4143
|
// PR4b emitter sweep — supergroup-mode permission card routing.
|
|
4141
4144
|
// Per CPO #3 the design is "turn-initiated requests follow the
|
|
4142
4145
|
// conversation topic; background requests go to admin alias."
|
|
@@ -14871,7 +14874,7 @@ async function registerSwitchroomBotCommands(): Promise<void> {
|
|
|
14871
14874
|
}
|
|
14872
14875
|
|
|
14873
14876
|
// ─── Inline-button handler (permissions) ──────────────────────────────────
|
|
14874
|
-
// Handles `perm:(allow|deny|
|
|
14877
|
+
// Handles `perm:(allow|deny|always|asn|asb|back):<id>` — permission request buttons
|
|
14875
14878
|
bot.on('callback_query:data', async ctx => {
|
|
14876
14879
|
const data = ctx.callbackQuery.data
|
|
14877
14880
|
|
|
@@ -15246,33 +15249,40 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15246
15249
|
}
|
|
15247
15250
|
|
|
15248
15251
|
// Permission request buttons.
|
|
15249
|
-
const m = /^perm:(allow|deny|
|
|
15252
|
+
const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
|
|
15250
15253
|
if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
|
|
15251
15254
|
const access = loadAccess()
|
|
15252
15255
|
const senderId = String(ctx.from.id)
|
|
15253
15256
|
if (!access.allowFrom.includes(senderId)) { await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {}); return }
|
|
15254
15257
|
const [, behavior, request_id] = m
|
|
15255
15258
|
|
|
15256
|
-
|
|
15259
|
+
// "🔁 Always…" / "← Back" — toggle between the default action row and
|
|
15260
|
+
// the scope sub-menu. Neither dispatches a verdict; the scope is only
|
|
15261
|
+
// committed when the operator taps a scope button (asn / asb).
|
|
15262
|
+
if (behavior === 'always' || behavior === 'back') {
|
|
15257
15263
|
const details = pendingPermissions.get(request_id)
|
|
15258
15264
|
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
15259
|
-
|
|
15260
|
-
|
|
15261
|
-
|
|
15262
|
-
|
|
15263
|
-
|
|
15264
|
-
|
|
15265
|
-
|
|
15266
|
-
|
|
15267
|
-
|
|
15268
|
-
|
|
15265
|
+
let keyboard: InlineKeyboard
|
|
15266
|
+
if (behavior === 'back') {
|
|
15267
|
+
keyboard = buildPermissionActionRow(request_id, true)
|
|
15268
|
+
} else {
|
|
15269
|
+
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
15270
|
+
if (choices == null) {
|
|
15271
|
+
await ctx.answerCallbackQuery({ text: 'No always-allow rule for this tool.' }).catch(() => {})
|
|
15272
|
+
return
|
|
15273
|
+
}
|
|
15274
|
+
// Scope row: ← Back · [this thing] · [any thing] ⚠️ — the ⚠️
|
|
15275
|
+
// rides on the broad option only. Scope stays hidden until now.
|
|
15276
|
+
keyboard = new InlineKeyboard().text('← Back', `perm:back:${request_id}`)
|
|
15277
|
+
if (choices.specific) keyboard.text(choices.specific.buttonLabel, `perm:asn:${request_id}`)
|
|
15278
|
+
keyboard.text(`${choices.broad.buttonLabel} ⚠️`, `perm:asb:${request_id}`)
|
|
15269
15279
|
}
|
|
15270
|
-
await ctx.
|
|
15280
|
+
await ctx.editMessageReplyMarkup({ reply_markup: keyboard }).catch(() => {})
|
|
15271
15281
|
await ctx.answerCallbackQuery().catch(() => {})
|
|
15272
15282
|
return
|
|
15273
15283
|
}
|
|
15274
15284
|
|
|
15275
|
-
if (behavior === '
|
|
15285
|
+
if (behavior === 'asn' || behavior === 'asb') {
|
|
15276
15286
|
// "🔁 Always allow" (#1977) — persist the resolved rule into the
|
|
15277
15287
|
// agent's tools.allow in the DURABLE host config. The old path
|
|
15278
15288
|
// shelled `switchroom agent grant` which wrote
|
|
@@ -15289,11 +15299,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15289
15299
|
// apply+reconcile result.
|
|
15290
15300
|
const details = pendingPermissions.get(request_id)
|
|
15291
15301
|
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
15292
|
-
const
|
|
15293
|
-
if (
|
|
15302
|
+
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
15303
|
+
if (choices == null) {
|
|
15294
15304
|
await ctx.answerCallbackQuery({ text: 'Cannot synthesize an always-allow rule for this tool.' }).catch(() => {})
|
|
15295
15305
|
return
|
|
15296
15306
|
}
|
|
15307
|
+
// asn → the narrow/specific scope (falls back to broad when the tool
|
|
15308
|
+
// has no sub-scope); asb → the broad, whole-category scope.
|
|
15309
|
+
const chosen = behavior === 'asn' ? (choices.specific ?? choices.broad) : choices.broad
|
|
15310
|
+
const grantPhrase = describeGrant(details.tool_name, details.input_preview, chosen)
|
|
15297
15311
|
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
15298
15312
|
if (!agentName) {
|
|
15299
15313
|
await ctx.answerCallbackQuery({ text: 'Always-allow needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {})
|
|
@@ -15304,7 +15318,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15304
15318
|
|
|
15305
15319
|
// (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
|
|
15306
15320
|
// any host round-trip — so the turn never blocks on persistence.
|
|
15307
|
-
// We carry the
|
|
15321
|
+
// We carry the chosen `rule` so the bridge caches it for the rest
|
|
15308
15322
|
// of the session and auto-allows matching tool calls from sub-agents
|
|
15309
15323
|
// (Task tool) + the parent without re-popping the prompt (#1138).
|
|
15310
15324
|
// The rule is safe to cache regardless of whether the *durable*
|
|
@@ -15313,7 +15327,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15313
15327
|
type: 'permission',
|
|
15314
15328
|
requestId: request_id,
|
|
15315
15329
|
behavior: 'allow',
|
|
15316
|
-
rule:
|
|
15330
|
+
rule: chosen.rule,
|
|
15317
15331
|
})
|
|
15318
15332
|
|
|
15319
15333
|
// (3) Decide the persistence path. tryHostdDispatch returns
|
|
@@ -15331,14 +15345,14 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15331
15345
|
try {
|
|
15332
15346
|
const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
|
|
15333
15347
|
const raw = readFileSync(cfgPath, 'utf8')
|
|
15334
|
-
return synthesizeAllowRuleDiff({ agentName, rule:
|
|
15348
|
+
return synthesizeAllowRuleDiff({ agentName, rule: chosen.rule, configText: raw })
|
|
15335
15349
|
} catch (err) {
|
|
15336
15350
|
process.stderr.write(`telegram gateway: always-allow diff synth failed: ${(err as Error).message}\n`)
|
|
15337
15351
|
return null
|
|
15338
15352
|
}
|
|
15339
15353
|
})()
|
|
15340
15354
|
|
|
15341
|
-
const correlationKey = `${agentName}::${
|
|
15355
|
+
const correlationKey = `${agentName}::${chosen.rule}`
|
|
15342
15356
|
try {
|
|
15343
15357
|
if (unifiedDiff == null) {
|
|
15344
15358
|
// Could not locate the agent block / read config → fall back to
|
|
@@ -15348,14 +15362,14 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15348
15362
|
} else {
|
|
15349
15363
|
// Pre-register the single-tap correlation so hostd's callback
|
|
15350
15364
|
// (request_config_approval) auto-approves WITHOUT a second card.
|
|
15351
|
-
pendingAlwaysAllowCorrelations.set(correlationKey, { agentName, rule:
|
|
15365
|
+
pendingAlwaysAllowCorrelations.set(correlationKey, { agentName, rule: chosen.rule, unifiedDiff, createdAt: Date.now() })
|
|
15352
15366
|
const req: HostdRequest = {
|
|
15353
15367
|
v: 1,
|
|
15354
15368
|
op: 'config_propose_edit',
|
|
15355
15369
|
request_id: hostdRequestId('gw-always-allow'),
|
|
15356
15370
|
args: {
|
|
15357
15371
|
unified_diff: unifiedDiff,
|
|
15358
|
-
reason: `Operator 'always allow'
|
|
15372
|
+
reason: `Operator 'always allow': ${agentName} can ${grantPhrase}`,
|
|
15359
15373
|
target_path: '/state/config/switchroom.yaml',
|
|
15360
15374
|
},
|
|
15361
15375
|
}
|
|
@@ -15368,7 +15382,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15368
15382
|
} else if (resp.result === 'completed') {
|
|
15369
15383
|
durable = true
|
|
15370
15384
|
process.stderr.write(
|
|
15371
|
-
`telegram gateway: always-allow durable via hostd rule="${
|
|
15385
|
+
`telegram gateway: always-allow durable via hostd rule="${chosen.rule}" agent=${agentName} (request_id=${request_id})\n`,
|
|
15372
15386
|
)
|
|
15373
15387
|
} else {
|
|
15374
15388
|
failReason = resp.error ?? `hostd ${resp.result}`
|
|
@@ -15386,20 +15400,20 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15386
15400
|
// landed. Honest messaging: "saved (legacy path)" on verify,
|
|
15387
15401
|
// else the "did NOT save" warning.
|
|
15388
15402
|
try {
|
|
15389
|
-
switchroomExec(['agent', 'grant', agentName,
|
|
15403
|
+
switchroomExec(['agent', 'grant', agentName, chosen.rule, '--no-restart'])
|
|
15390
15404
|
try {
|
|
15391
15405
|
const cfg = loadSwitchroomConfig()
|
|
15392
15406
|
const rawAgent = cfg.agents?.[agentName]
|
|
15393
15407
|
if (rawAgent) {
|
|
15394
15408
|
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
15395
15409
|
const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
|
|
15396
|
-
if (isRulePersisted(allowList,
|
|
15410
|
+
if (isRulePersisted(allowList, chosen.rule)) {
|
|
15397
15411
|
durable = true // legacy path verified — durable on this host shape
|
|
15398
15412
|
process.stderr.write(
|
|
15399
|
-
`telegram gateway: always-allow added rule="${
|
|
15413
|
+
`telegram gateway: always-allow added rule="${chosen.rule}" agent=${agentName} via legacy grant (request_id=${request_id})\n`,
|
|
15400
15414
|
)
|
|
15401
15415
|
} else {
|
|
15402
|
-
failReason = `rule "${
|
|
15416
|
+
failReason = `rule "${chosen.rule}" not found in resolved tools.allow after write — config location may have drifted`
|
|
15403
15417
|
process.stderr.write(
|
|
15404
15418
|
`telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
|
|
15405
15419
|
)
|
|
@@ -15431,8 +15445,8 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15431
15445
|
const legacyNote = legacy && durable
|
|
15432
15446
|
const ackText = ok
|
|
15433
15447
|
? (legacyNote
|
|
15434
|
-
?
|
|
15435
|
-
:
|
|
15448
|
+
? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
|
|
15449
|
+
: `✅ Saved. ${agentName} can now ${grantPhrase} without asking.`)
|
|
15436
15450
|
: (editLockHint
|
|
15437
15451
|
? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
|
|
15438
15452
|
: `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
|
|
@@ -15448,8 +15462,8 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15448
15462
|
: ''
|
|
15449
15463
|
const editLabel = ok
|
|
15450
15464
|
? (legacyNote
|
|
15451
|
-
?
|
|
15452
|
-
:
|
|
15465
|
+
? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect`
|
|
15466
|
+
: `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
|
|
15453
15467
|
: (editLockHint
|
|
15454
15468
|
? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
|
|
15455
15469
|
: `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
|
|
@@ -15469,13 +15483,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15469
15483
|
// Forward permission decision to connected bridges
|
|
15470
15484
|
pendingPermissions.delete(request_id)
|
|
15471
15485
|
const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
|
|
15472
|
-
// HTML-escape the source text — same hazard as the
|
|
15473
|
-
// recent-denial paths above. The
|
|
15474
|
-
//
|
|
15475
|
-
//
|
|
15476
|
-
// (
|
|
15477
|
-
// `description` and `input_preview` strings that frequently contain
|
|
15478
|
-
// raw `<`/`>`/`&` (shell commands, JSON nested in JSON, etc). Mixing
|
|
15486
|
+
// HTML-escape the source text — same hazard as the scope-commit and
|
|
15487
|
+
// recent-denial paths above. The permission card body
|
|
15488
|
+
// (formatPermissionCardBody) appends claude-supplied `description`
|
|
15489
|
+
// and `input_preview` strings that frequently contain raw
|
|
15490
|
+
// `<`/`>`/`&` (shell commands, JSON nested in JSON, etc). Mixing
|
|
15479
15491
|
// a plain-text edit with `parseMode: 'HTML'` is the safe direction:
|
|
15480
15492
|
// escaped baseText round-trips visually as plain text; the appended
|
|
15481
15493
|
// status line carries our chosen styling cleanly.
|
|
@@ -32,8 +32,10 @@ export interface PermissionEvent {
|
|
|
32
32
|
* tapped "Always allow" still hit the popup, because Claude Code reads
|
|
33
33
|
* `.claude/settings.json` once at boot.
|
|
34
34
|
*
|
|
35
|
-
* Format matches `
|
|
36
|
-
* (`Edit`), `
|
|
35
|
+
* Format matches `resolveScopedAllowChoices`' output: bare tool name
|
|
36
|
+
* (`Edit`), scoped (`Edit(<path>)` / `Bash(<tok>:*)` / `Skill(<name>)`),
|
|
37
|
+
* exact MCP tool (`mcp__<server>__<tool>`), or server wildcard
|
|
38
|
+
* (`mcp__<server>__*`).
|
|
37
39
|
*/
|
|
38
40
|
rule?: string;
|
|
39
41
|
}
|