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:
parent
1c5ba3041c
commit
7258de90c3
3 changed files with 167 additions and 78 deletions
|
|
@ -5848,6 +5848,8 @@ export class TeamProvisioningService {
|
|||
receivedAt: messageTimestamp || new Date().toISOString(),
|
||||
teamColor: run.request.color,
|
||||
teamDisplayName: run.request.displayName,
|
||||
permissionSuggestions:
|
||||
perm.permissionSuggestions.length > 0 ? perm.permissionSuggestions : undefined,
|
||||
};
|
||||
|
||||
const autoResult = shouldAutoAllow(
|
||||
|
|
@ -5859,7 +5861,14 @@ export class TeamProvisioningService {
|
|||
logger.info(
|
||||
`[${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({
|
||||
autoResolved: true,
|
||||
requestId: perm.requestId,
|
||||
|
|
@ -6046,14 +6055,14 @@ export class TeamProvisioningService {
|
|||
|
||||
const approval = run.pendingApprovals.get(requestId);
|
||||
if (approval && approval.source !== 'lead') {
|
||||
// Teammate request — respond via inbox + control_response fallback.
|
||||
// Defer cleanup until the async write completes to avoid silent data loss.
|
||||
// Teammate request — apply permission_suggestions to project settings.
|
||||
this.respondToTeammatePermission(
|
||||
run,
|
||||
approval.source,
|
||||
requestId,
|
||||
allow,
|
||||
allow ? undefined : 'Timed out — auto-denied by settings'
|
||||
allow ? undefined : 'Timed out — auto-denied by settings',
|
||||
approval.permissionSuggestions
|
||||
).finally(() => {
|
||||
run.pendingApprovals.delete(requestId);
|
||||
this.inFlightResponses.delete(requestId);
|
||||
|
|
@ -6132,7 +6141,14 @@ export class TeamProvisioningService {
|
|||
this.clearApprovalTimeout(requestId);
|
||||
if (!this.tryClaimResponse(requestId)) continue;
|
||||
if (approval.source !== 'lead') {
|
||||
void this.respondToTeammatePermission(run, approval.source, requestId, true);
|
||||
void this.respondToTeammatePermission(
|
||||
run,
|
||||
approval.source,
|
||||
requestId,
|
||||
true,
|
||||
undefined,
|
||||
approval.permissionSuggestions
|
||||
);
|
||||
} else {
|
||||
this.autoAllowControlRequest(run, requestId);
|
||||
}
|
||||
|
|
@ -6198,10 +6214,17 @@ export class TeamProvisioningService {
|
|||
|
||||
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') {
|
||||
try {
|
||||
await this.respondToTeammatePermission(run, approval.source, requestId, allow, message);
|
||||
await this.respondToTeammatePermission(
|
||||
run,
|
||||
approval.source,
|
||||
requestId,
|
||||
allow,
|
||||
message,
|
||||
approval.permissionSuggestions
|
||||
);
|
||||
} finally {
|
||||
run.pendingApprovals.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
|
||||
* AND attempting a control_response via stdin (belt-and-suspenders).
|
||||
* Respond to a teammate's permission_request by applying permission_suggestions.
|
||||
*
|
||||
* 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(
|
||||
run: ProvisioningRun,
|
||||
agentId: string,
|
||||
requestId: string,
|
||||
allow: boolean,
|
||||
message?: string
|
||||
_message?: string,
|
||||
permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[]
|
||||
): Promise<void> {
|
||||
const teamsBase = getTeamsBasePath();
|
||||
const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`);
|
||||
if (!allow) {
|
||||
logger.info(`[${run.teamName}] Denied teammate ${agentId} permission ${requestId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Write permission_response to teammate's inbox (with proper file locking)
|
||||
const responseMsg = {
|
||||
from: 'user',
|
||||
text: JSON.stringify({
|
||||
type: 'permission_response',
|
||||
request_id: requestId,
|
||||
approved: allow,
|
||||
...(message ? { message } : {}),
|
||||
}),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
};
|
||||
// Apply permission_suggestions: add tool rules to project settings file
|
||||
const suggestions = permissionSuggestions ?? [];
|
||||
if (suggestions.length === 0) {
|
||||
logger.warn(`[${run.teamName}] No permission_suggestions for ${requestId} — cannot add rule`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve project cwd from team config
|
||||
let projectCwd: string | undefined;
|
||||
try {
|
||||
await withFileLock(inboxPath, async () => {
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
let existing: unknown[] = [];
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(inboxPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_INBOX_MAX_BYTES,
|
||||
});
|
||||
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)
|
||||
}`
|
||||
);
|
||||
const config = await this.configReader.getConfig(run.teamName);
|
||||
projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
if (!projectCwd) {
|
||||
logger.warn(`[${run.teamName}] Cannot resolve project cwd for permission rule — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Also try control_response via stdin (in case lead runtime can forward it)
|
||||
if (run.child?.stdin?.writable) {
|
||||
const controlResponse = allow
|
||||
? {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { behavior: 'allow' },
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { behavior: 'deny', message: message ?? 'User denied' },
|
||||
},
|
||||
};
|
||||
run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => {
|
||||
if (err) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
for (const suggestion of suggestions) {
|
||||
if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue;
|
||||
|
||||
const toolNames = suggestion.rules
|
||||
.map((r) => r.toolName)
|
||||
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
||||
if (toolNames.length === 0) continue;
|
||||
|
||||
const behavior = suggestion.behavior ?? 'allow';
|
||||
// FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json)
|
||||
const settingsPath =
|
||||
suggestion.destination === 'localSettings'
|
||||
? path.join(projectCwd, '.claude', 'settings.local.json')
|
||||
: path.join(projectCwd, '.claude', 'settings.local.json'); // default to local
|
||||
|
||||
try {
|
||||
await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior);
|
||||
logger.info(
|
||||
`[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} → ${behavior} in ${settingsPath}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[${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.
|
||||
* Verifies provisioning files exist and marks as ready.
|
||||
|
|
|
|||
|
|
@ -888,6 +888,15 @@ export interface ToolApprovalRequest {
|
|||
teamColor?: string;
|
||||
/** Team display name (from config or create request). */
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -41,6 +41,14 @@ export function isInboxNoiseMessage(text: string): boolean {
|
|||
// 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. */
|
||||
export interface ParsedPermissionRequest {
|
||||
requestId: string;
|
||||
|
|
@ -49,6 +57,11 @@ export interface ParsedPermissionRequest {
|
|||
toolUseId: string;
|
||||
description: string;
|
||||
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 as Record<string, unknown>)
|
||||
: {},
|
||||
permissionSuggestions: Array.isArray(parsed.permission_suggestions)
|
||||
? (parsed.permission_suggestions as PermissionSuggestion[])
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue