跳转至

Walkthrough | 模拟 Permission Prompt

难度:⭐⭐ 时间:~1h 目标:手写一个 permission prompt UI(mock)


1. Permission Prompt 是什么

Permission Prompt = Claude Code 询问用户是否允许某个工具调用的 UI。 - 4 选项:Yes / Yes (file) / Yes (session) / No - 3 mode 影响 - 可 deny 加 rule

详见 docs/PERMISSIONS.md


2. 目标

手写一个简化版 permission prompt: - 4 选项 - 返回用户选择 - 加 rule - 集成 mock


3. 完整代码

// mock-permission-prompt.ts
import * as readline from 'readline/promises'

type Decision = 'allow-once' | 'allow-file' | 'allow-session' | 'deny'

interface PermissionPromptResult {
  decision: Decision
  rule?: string
}

interface PermissionContext {
  toolName: string
  toolInput: any
  reason?: string
  suggestedRule?: string
}

export class MockPermissionPrompt {
  async ask(context: PermissionContext): Promise<PermissionPromptResult> {
    this.display(context)

    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    })

    const answer = await rl.question('\nChoice: ')
    rl.close()

    return this.parse(answer.trim(), context)
  }

  private display(ctx: PermissionContext): void {
    console.log('\n' + '='.repeat(60))
    console.log(`Allow ${this.formatTool(ctx)}?`)
    console.log('='.repeat(60))

    if (ctx.reason) {
      console.log(`Reason: ${ctx.reason}`)
    }

    if (ctx.toolInput) {
      console.log('\nDetails:')
      const input = JSON.stringify(ctx.toolInput, null, 2)
        .split('\n')
        .map((line) => '  ' + line)
        .join('\n')
      console.log(input)
    }

    console.log('\nOptions:')
    console.log('  1. Yes (allow this time)')
    console.log('  2. Yes, and don\'t ask again for this tool on this file')
    console.log('  3. Yes, and don\'t ask again for this tool in this session')
    console.log('  4. No')

    if (ctx.suggestedRule) {
      console.log(`\nSuggested rule: ${ctx.suggestedRule}`)
    }
  }

  private parse(answer: string, ctx: PermissionContext): PermissionPromptResult {
    switch (answer) {
      case '1':
        return { decision: 'allow-once' }
      case '2':
        return {
          decision: 'allow-file',
          rule: this.buildFileRule(ctx),
        }
      case '3':
        return {
          decision: 'allow-session',
          rule: ctx.suggestedRule || this.buildSessionRule(ctx),
        }
      case '4':
        return { decision: 'deny' }
      default:
        return { decision: 'deny' }  // 默认 deny(保守)
    }
  }

  private formatTool(ctx: PermissionContext): string {
    const input = JSON.stringify(ctx.toolInput)
    if (input.length > 60) {
      return `${ctx.toolName}(${input.slice(0, 60)}...)`
    }
    return `${ctx.toolName}(${input})`
  }

  private buildFileRule(ctx: PermissionContext): string {
    const file = ctx.toolInput?.file_path
    if (file) {
      return `${ctx.toolName}(${file})`
    }
    return ctx.suggestedRule || ctx.toolName
  }

  private buildSessionRule(ctx: PermissionContext): string {
    return ctx.suggestedRule || ctx.toolName
  }
}

~120 行


4. 使用示例

const prompt = new MockPermissionPrompt()

const result = await prompt.ask({
  toolName: 'Bash',
  toolInput: { command: 'ls -la' },
  reason: 'Listed directory',
  suggestedRule: 'Bash(ls:*)',
})

console.log('Decision:', result.decision)
console.log('Rule:', result.rule)

2 步


5. 4 个选项详解

5.1 Yes (allow this time)

return { decision: 'allow-once' }

单次允许

5.2 Yes, file

return {
  decision: 'allow-file',
  rule: this.buildFileRule(ctx),  // 'Bash(/path/to/file)'
}

文件级允许

5.3 Yes, session

return {
  decision: 'allow-session',
  rule: ctx.suggestedRule || this.buildSessionRule(ctx),
}

session 级允许

5.4 No

return { decision: 'deny' }

拒绝


6. 4 步集成

6.1 集成 1: Bash tool

const decision = await prompt.ask({
  toolName: 'Bash',
  toolInput: { command },
  suggestedRule: `Bash(${extractPrefix(command)}:*)`,
})

Bash

6.2 集成 2: Edit

const decision = await prompt.ask({
  toolName: 'Edit',
  toolInput: { file_path, old_string, new_string },
  suggestedRule: `Edit(./${path.dirname(file_path)}/**)`,
})

Edit

6.3 集成 3: WebFetch

const decision = await prompt.ask({
  toolName: 'WebFetch',
  toolInput: { url },
  suggestedRule: `WebFetch(domain:${new URL(url).hostname})`,
})

WebFetch

6.4 集成 4: Agent

const decision = await prompt.ask({
  toolName: 'Agent',
  toolInput: { name, prompt },
  suggestedRule: `Agent(${name})`,
})

Agent


7. 5 个扩展

7.1 加 fuzzy match

// "y" / "n" 也接受
if (answer === 'y') return { decision: 'allow-once' }
if (answer === 'n') return { decision: 'deny' }

fuzzy

7.2 加 default

// 超时 → default
const timeout = setTimeout(() => rl.close(), 30000)
try {
  const answer = await rl.question(...)
} finally {
  clearTimeout(timeout)
}

default

7.3 加 reason 输入

// No 时要求 reason
if (decision === 'deny') {
  result.reason = await rl.question('Why? ')
}

reason

7.4 加 TUI

// 不用 readline,用 Ink
import { Box, Text, useInput } from 'ink'

function TUIPrompt({ onChoice }) {
  useInput((input, key) => {
    if (input === '1') onChoice('allow-once')
    // ...
  })
}

TUI

7.5 加 history

// 记忆上次选择
const lastChoice = history.get(toolName)
if (lastChoice === 'allow-session') {
  return { decision: 'allow-session' }
}

history


8. 5 个最佳实践

  1. default deny —— 未指定 → deny
  2. timeout —— 30s 默认
  3. show tool input —— 让用户理解
  4. suggested rule —— 一键添加
  5. file / session —— 粒度选择

9. 5 个常见情况

9.1 单次允许

Choice: 1
Decision: allow-once

9.2 文件允许

Choice: 2
Decision: allow-file
Rule: Bash(/path/to/file)

9.3 session 允许

Choice: 3
Decision: allow-session
Rule: Bash(ls:*)

9.4 拒绝

Choice: 4
Decision: deny

9.5 超时

(30s timeout)
Decision: deny (default)

10. 5 个测试

// 1. test allow-once
const r1 = await prompt.ask({ toolName: 'Bash', toolInput: { command: 'ls' } })
// simulate input '1'
// expect r1.decision === 'allow-once'

// 2. test allow-file
// simulate '2'
// expect r1.decision === 'allow-file'
// expect r1.rule === 'Bash(...)'

// 3. test deny
// simulate '4'
// expect r1.decision === 'deny'

// 4. test default
// simulate 'invalid'
// expect r1.decision === 'deny'

// 5. test timeout
// (no input for 30s)
// expect r1.decision === 'deny'

5 测


11. 总结

模拟 Permission Prompt = 4 选项 + suggested rule + 集成

核心: - 120 行简化版 - 4 选项 - rule 生成 - default deny

下一步: - 看真实 Claude Code - 加 TUI - 加 history