Skip to main content
Control & Safety

Permission Control

The Qoder Agent SDK's permission control capabilities manage what the model can do within a single query() session. It can restrict which tools are visible to the model, set default authorization policies, delegate tool execution approval to the host application, and apply new rules to the current session after user authorization. Permission control isn't a single API but a set of options. Typically you decide which tools the session may use, then when those tools may execute, then wire in runtime approval, dynamic rule updates, settings, or hooks as needed.
const messages = query({
  prompt: 'Inspect the repository and summarize risky changes.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    tools: ['Read', 'Grep', 'Bash'],
    allowedTools: ['Read', 'Grep'],
    disallowedTools: ['Bash'],
    permissionMode: 'default',
  },
});

for await (const message of messages) {
  console.log(message);
}
The example expresses a common policy: the model sees Read, Grep, and Bash; Read and Grep are pre-approved; Bash is denied. In real projects, add the permission callback (canUseTool / can_use_tool) to route non-pre-approved operations to your product UI, approval system, or risk service.

Capability overview

Permission-related options fall into four groups. The first sets the default policy—plan mode, auto-accept edits, no interactive prompts. The second sets tool scope and rules. The third brings the host into runtime approval. The fourth covers advanced settings, hooks, and MCP tool policy.
Problem to solveRecommended entry (TypeScript / Python)Notes
Set the session's default permission behaviorpermissionMode / permission_modeDecides how tool calls are handled when no explicit rule matches
Explicitly confirm skipping permission checksallowDangerouslySkipPermissions / allow_dangerously_skip_permissionsOnly with bypassPermissions or yolo
Restrict tools visible in this sessiontoolsTools not included are not provided to the model
Pre-approve certain toolsallowedTools / allowed_toolsMatches usually skip the authorization prompt
Deny certain toolsdisallowedTools / disallowed_toolsMatches are rejected; takes precedence over allow
Let the host approve tool callscanUseTool / can_use_toolThe SDK host returns allow or deny at runtime
Hand approval to an external prompt toolpermissionPromptToolName / permission_prompt_tool_nameFor runtimes that already provide a permission prompt tool
Update session rules after approvalPermissionUpdateCommonly "allow once" or "always allow this session"
Allow access outside cwdadditionalDirectories / add_dirsExtends the session's accessible directories
Provide permission rules from settingssettingsSuitable for static permission configuration at session start
Intercept or audit during the lifecyclehooksSuitable for advanced interception, auditing, and alerting
Declare tool policy on an MCP serverMCP tool policyDeclare per-tool allow/ask/deny in the MCP server config
To stay focused on permission config, later examples omit the message-consumption code; real usage still consumes the returned async message stream.

Quick Start: Host Application Approving Tool Calls

Use the permission callback when tool calls must go through your own approval logic. At runtime the SDK hands your callback the tool name, tool input, and a set of displayable approval details. Returning allow lets the tool run; deny rejects it.
const readOrder = tool(
  'read_order',
  'Read an order by ID.',
  { orderId: z.string() },
  async ({ orderId }) => ({
    content: [{ type: 'text', text: `order:${orderId}` }],
  }),
);

const server = createSdkMcpServer({
  name: 'orders',
  tools: [readOrder],
});

query({
  prompt: 'Read order 1001.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: { orders: server },
    permissionMode: 'default',
    async canUseTool(toolName, input, options) {
      if (toolName !== 'mcp__orders__read_order') {
        return {
          behavior: 'deny',
          message: 'Only order reads are allowed in this workflow.',
          toolUseID: options.toolUseID,
        };
      }

      return {
        behavior: 'allow',
        updatedInput: input,
        toolUseID: options.toolUseID,
      };
    },
  },
});
Here read_order is an SDK MCP tool. When the model calls it, the full tool name is mcp__orders__read_order. The permission callback only allows this tool and returns the original input as the updated input. In TypeScript, returning toolUseID lets the runtime match the approval result to this exact tool call.

Controlling Default Policy: permissionMode

The permission mode (permissionMode / permission_mode) sets the session's default permission policy. It expresses "what mode this session is in overall"—plan first, auto-accept edits, reject without asking, or skip checks in controlled environments.
query({
  prompt: 'Plan the migration. Do not edit files yet.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    permissionMode: 'plan',
  },
});
plan mode is designed for having the model produce a plan before making changes.
ModeBehavior
defaultStandard permission behavior. Tool calls are processed according to tools, allow/deny rules, dynamic approval, or runtime policy
acceptEditsAuto-accepts file edit operations; use this when workspace modification is confirmed
bypassPermissionsSkips permission checks; requires the explicit skip confirmation as well
yoloCompatibility alias of bypassPermissions; also requires explicit confirmation
planPlan mode; designed for producing an execution plan first; no actual changes by default
dontAskNo interactive prompts. Operations not pre-authorized or allowed by rules are denied
autoRuntime capability automatically determines allow or deny. Safe in-workspace file edits may be auto-approved
To switch modes within a session, use the runtime control method:
const q = query({
  prompt: 'Plan the change first.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    permissionMode: 'plan',
  },
});

await q.setPermissionMode('default');
bypassPermissions and yolo are high-risk modes. The SDK requires an explicit allowDangerouslySkipPermissions: true (Python: allow_dangerously_skip_permissions=True) so ordinary sessions can't accidentally skip permission checks.
query({
  prompt: 'Run the trusted local maintenance task.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    permissionMode: 'bypassPermissions',
    allowDangerouslySkipPermissions: true,
  },
});

Controlling Tool Scope: tools, allowedTools, disallowedTools

Tool control answers "which tools the model can see, and which are allowed or denied by default". The three fields often appear together but differ semantically.
query({
  prompt: 'Inspect the repo without modifying it.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    tools: ['Read', 'Grep', 'Bash'],
    allowedTools: ['Read', 'Grep'],
    disallowedTools: ['Bash'],
  },
});
This configuration means: only provide Read, Grep, and Bash tools for this session; Read and Grep are pre-authorized; Bash is denied — even if the model wants to call it, it won't execute.
Field (TypeScript / Python)EffectBest for
tools / toolsRestricts the session's available tool setNarrowing the model's capability surface
allowedTools / allowed_toolsAdds allow rulesLetting low-risk tools skip repeated approval
disallowedTools / disallowed_toolsAdds deny rulesExplicitly banning high-risk tools
When the same tool matches both allow and deny, deny takes priority. This ensures deny rules cannot be bypassed by broader allow rules. MCP tools also use full tool name matching. For example, with SDK MCP server named orders and tool named read_order, the full tool name is mcp__orders__read_order.
query({
  prompt: 'Read order 1001.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: { orders: server },
    allowedTools: ['mcp__orders__read_order'],
  },
});

Runtime Approval: canUseTool

The permission callback suits hosts that take part in approval—showing permission requests in your own UI with "allow once", "always allow this session", or "deny"; or asking an enterprise risk service whether a command may run.
query({
  prompt: 'Create a changelog file for this release.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    permissionMode: 'default',
    async canUseTool(toolName, input, options) {
      showApprovalDialog({
        title: options.title ?? toolName,
        description: options.description,
        input,
      });

      const approved = await waitForUserApproval(options.signal);

      if (!approved) {
        return {
          behavior: 'deny',
          message: 'Rejected by user.',
          toolUseID: options.toolUseID,
        };
      }

      return {
        behavior: 'allow',
        updatedInput: input,
        toolUseID: options.toolUseID,
      };
    },
  },
});
The callback signature:
type CanUseTool = (
  toolName: string,
  input: Record<string, unknown>,
  options: {
    signal: AbortSignal;
    suggestions?: PermissionUpdate[];
    blockedPath?: string;
    decisionReason?: string;
    title?: string;
    displayName?: string;
    description?: string;
    toolUseID: string;
    agentID?: string;
  },
) => Promise<PermissionResult>;
Key fields (on the third options parameter in TypeScript, on context in Python):
FieldDescription
Tool nameFull tool name, e.g. Read, Bash, mcp__orders__read_order
InputThe tool call's original arguments
toolUseID / tool_use_idThis tool call's ID; in TypeScript, include it in the returned result
signalFires when the authorization request is cancelled; UIs or remote approvals should watch it
title / displayName / descriptionRuntime-generated human-readable text, ready for approval UIs
suggestionsRuntime-suggested permission updates, usable for "always allow this session"
blockedPath / blocked_pathThe restricted path in path-related authorization scenarios
decisionReason / decision_reasonRuntime-provided reasoning, for display or audit
agentID / agent_idThe agent ID when a subagent initiated the tool call
Return allow to continue executing the tool:
return {
  behavior: 'allow',
  updatedInput: input,
  toolUseID: options.toolUseID,
};
The updated input is what the tool ultimately receives. Return it unchanged, or modify it post-approval—adding a tenant ID, rewriting paths into a safe directory, or stripping disallowed fields. Return deny to reject the tool:
return {
  behavior: 'deny',
  message: 'This command is not allowed in the current workspace.',
  toolUseID: options.toolUseID,
};
deny.message is required; it becomes part of the rejection reason for the model, logs, or host display. If the SDK receives a CLI authorization request without a configured permission callback, it returns an error—nothing is allowed by default. When the permission system directly denies a tool call, a structured permission denial message may appear in the message stream:
type SDKPermissionDeniedMessage = {
  type: 'system';
  subtype: 'permission_denied';
  tool_name: string;
  tool_use_id?: string;
  message?: string;
  decision_reason?: string;
  decision_reason_type?: string;
};
Such messages typically appear with dontAsk mode, auto-rejects, or rule rejects. Hosts can use them to update UI state or write audit logs.

Updating Permissions Within a Session: PermissionUpdate

PermissionUpdate updates the session's permission rules after an approval. The most common case is the user choosing "always allow this session" in the approval UI. Return the runtime's suggestions as-is, or construct explicit rules yourself.
async function canUseTool(toolName, input, options) {
  const decision = await showApprovalDialog({
    toolName,
    suggestions: options.suggestions,
  });

  if (decision === 'always-allow-this-session') {
    return {
      behavior: 'allow',
      updatedInput: input,
      toolUseID: options.toolUseID,
      updatedPermissions: options.suggestions,
    };
  }

  if (decision === 'allow-once') {
    return {
      behavior: 'allow',
      updatedInput: input,
      toolUseID: options.toolUseID,
    };
  }

  return {
    behavior: 'deny',
    message: 'Rejected by user.',
    toolUseID: options.toolUseID,
  };
}
You can also construct rules directly:
return {
  behavior: 'allow',
  updatedInput: input,
  toolUseID: options.toolUseID,
  updatedPermissions: [
    {
      type: 'addRules',
      behavior: 'allow',
      destination: 'session',
      rules: [{ toolName: 'mcp__orders__read_order' }],
    },
  ],
};
Supported update types:
TypePurpose
addRulesAppend allow, ask, or deny rules
replaceRulesReplace rules
removeRulesRemove rules
setModeSwitch permission mode
addDirectoriesAppend allowed access directories
removeDirectoriesRemove directory authorizations
Prefer writing dynamic permission updates to the current session (destination: 'session'). session only affects subsequent checks in this query session. For persistence at local/project/user level, prefer a settings-management flow over dynamic updates from a single approval callback.

Additional directories

By default the session treats cwd as the primary working directory. When the model must read or modify directories outside cwd, pass them explicitly (TypeScript: additionalDirectories; Python: add_dirs).
query({
  prompt: 'Inspect the app and the shared package.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/repo/app',
    additionalDirectories: ['/repo/packages/shared'],
  },
});
This configuration means the session's main working directory is /repo/app, and the model is also allowed to access /repo/packages/shared. This works well for monorepos, cross-repository debugging, shared library investigation, and similar scenarios. During execution, directory authorization can also be adjusted via PermissionUpdate:
return {
  behavior: 'allow',
  updatedInput: input,
  toolUseID: options.toolUseID,
  updatedPermissions: [
    {
      type: 'addDirectories',
      destination: 'session',
      directories: ['/repo/packages/shared'],
    },
  ],
};
Directory authorization is part of the permission boundary. Don't make broad directories a default; add the minimal set per task instead.

External Authorization Tool: permissionPromptToolName

This option (TypeScript: permissionPromptToolName; Python: permission_prompt_tool_name) hands permission requests to a permission prompt tool in the runtime environment instead of implementing the permission callback in the SDK host. It suits existing external approval tools, remote runtimes, or unified permission gateways.
query({
  prompt: 'Run the task.',
  options: {
    auth: accessTokenFromEnv(),
    permissionPromptToolName: 'mcp__permission_server__approve',
  },
});
Three things to note:
  • It must be a prompt tool name the current runtime recognizes.
  • It is mutually exclusive with the permission callback (canUseTool / can_use_tool).
  • When the SDK host itself decides approvals, prefer the permission callback.
The permission prompt tool receives the following input:
type PermissionPromptToolInput = {
  tool_name: string;
  input: Record<string, unknown>;
  tool_use_id?: string;
};
It needs to return a permission result:
type PermissionPromptToolOutput =
  | {
      behavior: 'allow';
      updatedInput: Record<string, unknown>;
      updatedPermissions?: PermissionUpdate[];
      toolUseID?: string;
    }
  | {
      behavior: 'deny';
      message: string;
      interrupt?: boolean;
      toolUseID?: string;
    };
allow.updatedInput is the final parameters used when executing the tool. If you want to keep the original parameters, return the received input as-is. deny.message is required. interrupt: true means deny and also interrupt the current Agent flow.

Using settings to Provide Permission Rules

settings provides static permission configuration before the session starts. It beats the permission callback at expressing "what this project allows, denies, and which extra directories exist".
query({
  prompt: 'Inspect the project.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    settings: {
      permissions: {
        allow: ['Read', 'Grep'],
        deny: ['Bash'],
        ask: ['Write'],
        defaultMode: 'default',
        additionalDirectories: ['/path/to/shared-lib'],
      },
    },
  },
});
Field descriptions:
FieldDescription
permissions.allowAllow rules
permissions.denyDeny rules
permissions.askAlways-ask rules
permissions.defaultModeDefault permission mode
permissions.disableBypassPermissionsModeSet to 'disable' to disable bypass permissions mode
permissions.additionalDirectoriesAdditional accessible directories
If your application reads and applies the default permission mode from settings, consider performing your own product-level confirmation before executing high-risk modes. Modes like bypassPermissions and yolo should only appear in explicitly trusted environments.

Using hooks for Advanced Interception and Auditing

Hooks suit teams already on the SDK hooks system that want finer-grained control across the tool lifecycle. Compared with the permission callback, hooks fit cross-cutting logic: auditing, alerting, unified interception, recording rejection reasons.
query({
  prompt: 'Inspect the repo.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    hooks: {
      PreToolUse: [
        {
          matcher: 'Bash',
          hooks: [
            async (input) => {
              return {
                hookSpecificOutput: {
                  hookEventName: 'PreToolUse',
                  permissionDecision: 'deny',
                  permissionDecisionReason: 'Shell commands are disabled here.',
                },
              };
            },
          ],
        },
      ],
    },
  },
});
The main permission-related hooks are three types:
HookTrigger TimingCommon Use
PreToolUseBefore tool invocationPre-allow, deny, request ask, or pass to subsequent flow
PermissionRequestWhen entering a permission requestReturn allow or deny directly before the normal prompt
PermissionDeniedAfter permission is deniedAuditing, alerting, recording denial reasons
PreToolUse can return:
{
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    permissionDecision: 'allow' | 'deny' | 'ask' | 'defer',
    permissionDecisionReason?: string,
    updatedInput?: Record<string, unknown>,
  },
}
PermissionRequest can return a permission result similar to tool approval:
{
  hookSpecificOutput: {
    hookEventName: 'PermissionRequest',
    decision: {
      behavior: 'deny',
      message: 'Denied by policy.',
    },
  },
}
PermissionDenied is typically used for observing results, not for allowing tools. Its input includes the denied tool name, tool input, tool invocation ID, and denial reason.

MCP Tool Policy

If a permission policy naturally belongs to one MCP server, declare tool-level permission policy right in the MCP server config. The policy travels with the server config instead of being scattered across global tool allow/deny lists.
query({
  prompt: 'Use repo tools.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: {
      repo_tools: {
        type: 'http',
        url: process.env.REPO_TOOLS_MCP_URL!,
        tools: [
          { name: 'search', permission_policy: 'always_allow' },
          { name: 'write_file', permission_policy: 'always_ask' },
          { name: 'delete_file', permission_policy: 'always_deny' },
        ],
      },
    },
  },
});
Policy meanings:
PolicyBehavior
always_allowMatched tool is directly allowed
always_askMatched tool enters authorization flow
always_denyMatched tool is directly denied
name can be the MCP tool's original name or the full tool name, e.g., mcp__repo_tools__search. During actual matching, the runtime maps policy names to the current MCP tool invocation.