Skip to content

feat(tool-approval): add approval plugin and playgrounds#21

Open
exoticknight wants to merge 1 commit into
mainfrom
codex-tool-approval-v1
Open

feat(tool-approval): add approval plugin and playgrounds#21
exoticknight wants to merge 1 commit into
mainfrom
codex-tool-approval-v1

Conversation

@exoticknight
Copy link
Copy Markdown
Member

@exoticknight exoticknight commented May 24, 2026

architecture

flowchart TD
  A["Agent turn starts"] --> B["Core resolves tools"]
  B --> C["Core wraps final merged tools"]
  C --> D["Model requests tool call"]
  D --> E["preToolCall hooks"]
  E -->|continue| F["Execute real tool"]
  E -->|block| G["Return blocked tool result"]
  F --> H["postToolCall status: success/error"]
  G --> I["postToolCall status: blocked"]
  H --> J["Tool result returns to model"]
  I --> J
Loading
flowchart LR
  A["toolApproval plugin"] --> B["policy mode"]
  B --> C["off"]
  B --> D["allow"]
  B --> E["ask"]
  B --> F["deny"]

  E --> G["policy(request)"]
  G --> H["allow once"]
  G --> I["allow turn"]
  G --> J["allow conversation"]
  G --> K["deny / ask fallback"]

  I --> L["turnAllows Map"]
  J --> M["plugin private state"]
  L --> N["cleared on turn done"]
  M --> O["persisted with session storage"]
Loading

Core now owns the generic tool-call control pipeline. It does not know about approval policy semantics. It only asks plugins whether a tool call should continue or be blocked, then reports the final tool-call status through postToolCall.

The approval plugin is implemented as a normal plugin on top of that pipeline. It owns approval modes, policy decisions, approval key generation, remembered allow scopes, and decision events. Conversation-level history is stored in plugin private state instead of normal context, so regular setContext() updates cannot forge trusted approvals.

Tool metadata is intentionally kept outside existing tool plugins by using approval hint wrappers. Existing plugins do not need to know whether approval is enabled; the agent/plugin user can wrap tools or plugins with approval hints when they want richer approval UI and policy matching.

feat

  • Add core plugin private state support so plugins can persist trusted internal state outside mutable session context.
  • Add core preToolCall / postToolCall pipeline around resolved tools, including continue, block, success, error, and blocked statuses.
  • Add @apeira/plugin-tool-approval with off, allow, ask, and deny modes, runtime policy updates, approval keys, and once / turn / conversation scopes.
  • Add lightweight approval hint helpers and plugin wrappers so approval metadata can be supplied without changing existing tool plugins.
  • Add CLI and browser UI playgrounds for testing approval behavior with replayed agent/tool-call flows.

chore

  • Add README documentation for the tool approval plugin and example usage.
  • Add coverage for core private state, tool-call blocking, post-tool-call notifications, approval scopes, policy overrides, approval keys, and playground scenarios.
  • Register the new plugin and example packages in workspace dependencies and lockfile.

verification

  • node_modules\.bin\tsc.CMD --noEmit --pretty false
  • node_modules\.bin\vitest.CMD run packages\core\test\index.test.ts packages\plugin-tool-approval\test\index.test.ts
  • node_modules\.bin\eslint.CMD --flag unstable_native_nodejs_ts_config .
  • Eslint reports 0 errors; remaining warnings are existing TODO warnings outside this change.

…a agents

- Implemented the core functionality for tool approval, allowing agents to manage tool calls based on defined policies.
- Created README documentation detailing installation, usage, and API.
- Added package.json for plugin metadata and dependencies.
- Developed the main plugin logic in src/index.ts, including approval decision-making and state management.
- Included tests to validate approval behavior and policy enforcement.
- Updated pnpm-lock.yaml to include new dependencies for examples and testing.
@exoticknight exoticknight requested a review from kwaa May 24, 2026 13:08
@exoticknight exoticknight self-assigned this May 24, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new tool approval plugin and associated examples for the Apeira agent framework. Key changes include updates to the core plugin system to support preToolCall and postToolCall hooks, the addition of private state management for plugins, and the implementation of the @apeira/plugin-tool-approval package. Feedback focuses on improving the robustness of the stableStringify utility to handle undefined values and locale-independent sorting, addressing a potential memory leak in the session version tracking map, and mitigating XSS vulnerabilities in the UI playground caused by unsanitized innerHTML usage.

Comment on lines +194 to +210
const stableStringify = (value: unknown): string => {
if (value == null || typeof value !== 'object')
return JSON.stringify(value)

if (Array.isArray(value))
return `[${value.map(item => stableStringify(item)).join(',')}]`

const serializedEntries = Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => {
const serialized = stableStringify(item)
return `${JSON.stringify(key)}:${serialized}`
})
.join(',')

return `{${serializedEntries}}`
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The stableStringify implementation has a few issues:

  1. It doesn't handle undefined values correctly within arrays or objects. String interpolation of undefined results in the string "undefined", and join(',') on an array containing undefined results in invalid JSON-like strings (e.g., [1,,2]).
  2. localeCompare is locale-dependent, which might lead to different keys for the same input across different environments.
  3. It doesn't filter out undefined properties in objects, which JSON.stringify normally does.

Using a more robust implementation that matches JSON.stringify behavior for undefined and uses a stable sort order is recommended.

const stableStringify = (value: unknown): string => {
  if (typeof value !== 'object' || value === null) {
    return JSON.stringify(value) ?? 'null'
  }

  if (Array.isArray(value)) {
    return `[${value.map(item => (item === undefined ? 'null' : stableStringify(item))).join(',')}]`
  }

  const keys = Object.keys(value).sort()
  const entries: string[] = []

  for (const key of keys) {
    const val = (value as Record<string, unknown>)[key]
    if (val !== undefined) {
      entries.push(`${JSON.stringify(key)}:${stableStringify(val)}`)
    }
  }

  return `{${entries.join(',')}}`
}

Comment on lines +348 to +360
const clearedSessionVersions = new Map<string, number>()
const turnAllows = new Map<string, Set<string>>()

const applyPendingConversationClear = (privateState: PluginPrivateStateApi | undefined, sessionId: string) => {
if (clearConversationHistoryVersion === 0)
return

if (clearedSessionVersions.get(sessionId) === clearConversationHistoryVersion)
return

setPrivateState(privateState, createInitialState())
clearedSessionVersions.set(sessionId, clearConversationHistoryVersion)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The clearedSessionVersions map grows indefinitely as new sessions are processed, leading to a memory leak in long-running processes (like a server).

A better approach is to store the clearedVersion within the session's private state itself. This allows each session to lazily clear its own history when it encounters a higher global clearConversationHistoryVersion, without the plugin needing to track every session ID globally in a map that never gets cleaned up.

Comment on lines +448 to +453
const renderedMessages = state.messages.map(message => `
<article class="message ${message.kind}">
<span class="label">${message.kind}</span>
${message.text}
</article>
`).join('') || '<p class="empty-chat">Send a message to start the fake agent turn.</p>'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using innerHTML to render messages and other dynamic content without sanitization introduces a Cross-Site Scripting (XSS) vulnerability. If the agent output or user input contains malicious HTML/scripts, they will be executed in the browser.

Even for a playground, it's best practice to escape dynamic content or use textContent where possible to prevent script injection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant