fix(team): apply permission_suggestions to settings instead of writing to inbox

FACT: Claude Code runtime ignores permission_response in teammate inbox.
FACT: permission_request contains permission_suggestions from runtime
with instructions to add rules to project settings.
FACT: destination "localSettings" = {cwd}/.claude/settings.local.json.

When user clicks Allow for teammate permission_request:
- Parse permission_suggestions from the request
- Add tool rules to {cwd}/.claude/settings.local.json
- Creates directory/file if missing, merges with existing rules
- Teammate retries tool call, finds rule, succeeds

Removed: inbox permission_response write (didn't work)
Removed: control_response via stdin fallback (didn't work)
This commit is contained in:
iliya 2026-03-30 12:45:03 +03:00
parent 1c5ba3041c
commit 7258de90c3
3 changed files with 167 additions and 78 deletions

View file

@ -5848,6 +5848,8 @@ export class TeamProvisioningService {
receivedAt: messageTimestamp || new Date().toISOString(), receivedAt: messageTimestamp || new Date().toISOString(),
teamColor: run.request.color, teamColor: run.request.color,
teamDisplayName: run.request.displayName, teamDisplayName: run.request.displayName,
permissionSuggestions:
perm.permissionSuggestions.length > 0 ? perm.permissionSuggestions : undefined,
}; };
const autoResult = shouldAutoAllow( const autoResult = shouldAutoAllow(
@ -5859,7 +5861,14 @@ export class TeamProvisioningService {
logger.info( logger.info(
`[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})`
); );
void this.respondToTeammatePermission(run, perm.agentId, perm.requestId, true); void this.respondToTeammatePermission(
run,
perm.agentId,
perm.requestId,
true,
undefined,
perm.permissionSuggestions
);
this.emitToolApprovalEvent({ this.emitToolApprovalEvent({
autoResolved: true, autoResolved: true,
requestId: perm.requestId, requestId: perm.requestId,
@ -6046,14 +6055,14 @@ export class TeamProvisioningService {
const approval = run.pendingApprovals.get(requestId); const approval = run.pendingApprovals.get(requestId);
if (approval && approval.source !== 'lead') { if (approval && approval.source !== 'lead') {
// Teammate request — respond via inbox + control_response fallback. // Teammate request — apply permission_suggestions to project settings.
// Defer cleanup until the async write completes to avoid silent data loss.
this.respondToTeammatePermission( this.respondToTeammatePermission(
run, run,
approval.source, approval.source,
requestId, requestId,
allow, allow,
allow ? undefined : 'Timed out — auto-denied by settings' allow ? undefined : 'Timed out — auto-denied by settings',
approval.permissionSuggestions
).finally(() => { ).finally(() => {
run.pendingApprovals.delete(requestId); run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId); this.inFlightResponses.delete(requestId);
@ -6132,7 +6141,14 @@ export class TeamProvisioningService {
this.clearApprovalTimeout(requestId); this.clearApprovalTimeout(requestId);
if (!this.tryClaimResponse(requestId)) continue; if (!this.tryClaimResponse(requestId)) continue;
if (approval.source !== 'lead') { if (approval.source !== 'lead') {
void this.respondToTeammatePermission(run, approval.source, requestId, true); void this.respondToTeammatePermission(
run,
approval.source,
requestId,
true,
undefined,
approval.permissionSuggestions
);
} else { } else {
this.autoAllowControlRequest(run, requestId); this.autoAllowControlRequest(run, requestId);
} }
@ -6198,10 +6214,17 @@ export class TeamProvisioningService {
const approval = run.pendingApprovals.get(requestId)!; const approval = run.pendingApprovals.get(requestId)!;
// Teammate permission requests use a different response path (inbox, not stdin) // Teammate permission requests: apply permission_suggestions to project settings
if (approval.source !== 'lead') { if (approval.source !== 'lead') {
try { try {
await this.respondToTeammatePermission(run, approval.source, requestId, allow, message); await this.respondToTeammatePermission(
run,
approval.source,
requestId,
allow,
message,
approval.permissionSuggestions
);
} finally { } finally {
run.pendingApprovals.delete(requestId); run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId); this.inFlightResponses.delete(requestId);
@ -6284,92 +6307,133 @@ export class TeamProvisioningService {
} }
/** /**
* Respond to a teammate's permission_request by writing to the teammate's inbox * Respond to a teammate's permission_request by applying permission_suggestions.
* AND attempting a control_response via stdin (belt-and-suspenders). *
* FACT: Claude Code teammate runtime sends permission_request via SendMessage (inbox protocol).
* FACT: Writing permission_response to teammate inbox does NOT work - runtime ignores it.
* FACT: control_response via stdin does NOT work for teammate requests - request_id doesn't match.
* FACT: permission_suggestions.destination "localSettings" refers to {cwd}/.claude/settings.local.json.
* FACT: Claude Code CLI reads this file via --setting-sources user,project,local.
*
* When allow=true: applies permission_suggestions (adds tool rules to project settings).
* When allow=false: no action needed - tool stays blocked by default.
*/ */
private async respondToTeammatePermission( private async respondToTeammatePermission(
run: ProvisioningRun, run: ProvisioningRun,
agentId: string, agentId: string,
requestId: string, requestId: string,
allow: boolean, allow: boolean,
message?: string _message?: string,
permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[]
): Promise<void> { ): Promise<void> {
const teamsBase = getTeamsBasePath(); if (!allow) {
const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`); logger.info(`[${run.teamName}] Denied teammate ${agentId} permission ${requestId}`);
return;
}
// 1. Write permission_response to teammate's inbox (with proper file locking) // Apply permission_suggestions: add tool rules to project settings file
const responseMsg = { const suggestions = permissionSuggestions ?? [];
from: 'user', if (suggestions.length === 0) {
text: JSON.stringify({ logger.warn(`[${run.teamName}] No permission_suggestions for ${requestId} — cannot add rule`);
type: 'permission_response', return;
request_id: requestId, }
approved: allow,
...(message ? { message } : {}),
}),
timestamp: new Date().toISOString(),
read: false,
};
// Resolve project cwd from team config
let projectCwd: string | undefined;
try { try {
await withFileLock(inboxPath, async () => { const config = await this.configReader.getConfig(run.teamName);
await withInboxLock(inboxPath, async () => { projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd;
let existing: unknown[] = []; } catch {
try { // best-effort
const raw = await tryReadRegularFileUtf8(inboxPath, { }
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, if (!projectCwd) {
maxBytes: TEAM_INBOX_MAX_BYTES, logger.warn(`[${run.teamName}] Cannot resolve project cwd for permission rule — skipping`);
}); return;
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) existing = parsed;
}
} catch {
// File may not exist yet — start with empty array
}
existing.push(responseMsg);
await atomicWriteAsync(inboxPath, JSON.stringify(existing, null, 2));
});
});
logger.info(
`[${run.teamName}] Wrote permission_response to ${agentId} inbox: ${allow ? 'allow' : 'deny'}`
);
} catch (error) {
logger.error(
`[${run.teamName}] Failed to write permission_response to ${agentId}: ${
error instanceof Error ? error.message : String(error)
}`
);
} }
// 2. Also try control_response via stdin (in case lead runtime can forward it) for (const suggestion of suggestions) {
if (run.child?.stdin?.writable) { if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue;
const controlResponse = allow
? { const toolNames = suggestion.rules
type: 'control_response', .map((r) => r.toolName)
response: { .filter((name): name is string => typeof name === 'string' && name.length > 0);
subtype: 'success', if (toolNames.length === 0) continue;
request_id: requestId,
response: { behavior: 'allow' }, const behavior = suggestion.behavior ?? 'allow';
}, // FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json)
} const settingsPath =
: { suggestion.destination === 'localSettings'
type: 'control_response', ? path.join(projectCwd, '.claude', 'settings.local.json')
response: { : path.join(projectCwd, '.claude', 'settings.local.json'); // default to local
subtype: 'success',
request_id: requestId, try {
response: { behavior: 'deny', message: message ?? 'User denied' }, await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior);
}, logger.info(
}; `[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')}${behavior} in ${settingsPath}`
run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { );
if (err) { } catch (error) {
logger.warn( logger.error(
`[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` `[${run.teamName}] Failed to add permission rules: ${
); error instanceof Error ? error.message : String(error)
} }`
}); );
}
} }
} }
/**
* Safely add tool names to the permissions.allow (or deny) array in a Claude settings file.
* Creates the file and parent directories if they don't exist.
* Merges with existing entries never overwrites.
*/
private async addPermissionRulesToSettings(
settingsPath: string,
toolNames: string[],
behavior: string
): Promise<void> {
const dir = path.dirname(settingsPath);
await fs.promises.mkdir(dir, { recursive: true });
// Read existing settings (or start with empty object)
let settings: Record<string, unknown> = {};
try {
const raw = await fs.promises.readFile(settingsPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
settings = parsed as Record<string, unknown>;
}
} catch {
// File doesn't exist or invalid JSON — start fresh
}
// Ensure permissions object exists
if (!settings.permissions || typeof settings.permissions !== 'object') {
settings.permissions = {};
}
const perms = settings.permissions as Record<string, unknown>;
// Target array: "allow" or "deny" based on behavior
const key = behavior === 'deny' ? 'deny' : 'allow';
if (!Array.isArray(perms[key])) {
perms[key] = [];
}
const list = perms[key] as string[];
// Add tool names that aren't already in the list
const existing = new Set(list);
let added = 0;
for (const name of toolNames) {
if (!existing.has(name)) {
list.push(name);
added++;
}
}
if (added === 0) return; // Nothing new to add
await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
}
/** /**
* Called when the first stream-json turn completes successfully. * Called when the first stream-json turn completes successfully.
* Verifies provisioning files exist and marks as ready. * Verifies provisioning files exist and marks as ready.

View file

@ -888,6 +888,15 @@ export interface ToolApprovalRequest {
teamColor?: string; teamColor?: string;
/** Team display name (from config or create request). */ /** Team display name (from config or create request). */
teamDisplayName?: string; teamDisplayName?: string;
/** Permission suggestions from teammate runtime (only for teammate permission_request).
* FACT: Populated by Claude Code runtime, contains instructions to add permission rules.
*/
permissionSuggestions?: {
type: string;
rules?: { toolName: string }[];
behavior?: string;
destination?: string;
}[];
} }
/** Dismissal event — process died, all pending approvals for this team+run should be removed. */ /** Dismissal event — process died, all pending approvals for this team+run should be removed. */

View file

@ -41,6 +41,14 @@ export function isInboxNoiseMessage(text: string): boolean {
// Teammate permission request parsing // Teammate permission request parsing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** A single permission suggestion from the teammate runtime. */
export interface PermissionSuggestion {
type: string;
rules?: { toolName: string }[];
behavior?: string;
destination?: string;
}
/** Parsed teammate permission request from inbox message. */ /** Parsed teammate permission request from inbox message. */
export interface ParsedPermissionRequest { export interface ParsedPermissionRequest {
requestId: string; requestId: string;
@ -49,6 +57,11 @@ export interface ParsedPermissionRequest {
toolUseId: string; toolUseId: string;
description: string; description: string;
input: Record<string, unknown>; input: Record<string, unknown>;
/** Suggestions from teammate runtime on how to resolve the permission.
* FACT: This field is populated by Claude Code runtime, not by the AI agent.
* FACT: Observed format: { type: "addRules", rules: [{toolName}], behavior: "allow", destination: "localSettings" }
*/
permissionSuggestions: PermissionSuggestion[];
} }
/** /**
@ -75,6 +88,9 @@ export function parsePermissionRequest(text: string): ParsedPermissionRequest |
parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input)
? (parsed.input as Record<string, unknown>) ? (parsed.input as Record<string, unknown>)
: {}, : {},
permissionSuggestions: Array.isArray(parsed.permission_suggestions)
? (parsed.permission_suggestions as PermissionSuggestion[])
: [],
}; };
} }