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(),
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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[])
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue