Skip to content

[EPIC-X][LOU-X2] Declarative permission policies evaluated before needsApproval, with an audit log - #105

Merged
LinuxDevil merged 2 commits into
mainfrom
lou-x2-permission-policies
Oct 1, 2026
Merged

LinuxDevil merged 2 commits into
mainfrom
lou-x2-permission-policies

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

What

  • New src/execution/permissions.ts: PermissionRule = { tool: string | string[] | RegExp | '*'; when?: (args, { toolName, toolCallId, sessionId? }) => boolean | Promise<boolean>; action: 'allow' | 'deny' | 'ask'; reason? }, helpers allow(tools), deny(tools, reason?), ask(tools), and PermissionOptions (permissions, onPermissionDecision).
  • permissions / onPermissionDecision on createAgent() (CreateAgentBase extends PermissionOptions, passed through runOptions so resumed runs and sub-agent use get them too), ExecuteOptions (extends PermissionOptions) and so resumeAfterApproval() options.
  • toolCallExecution.ts: after validation and preToolCall hooks, rules are checked in order before needsApproval. The first match decides: allow runs without approval, deny returns a structured tool error (kind: 'denied', ToolDeniedError, reason) and so emits tool.error, ask takes the existing approval pause (or the approve callback). No match falls through to needsApproval. A throwing when becomes the call's error, the same as a throwing needsApproval.
  • Audit log: onPermissionDecision({ toolName, toolCallId, decision: 'allow'|'deny'|'ask'|'default', rule?: { index, reason? }, args?, at }). args is left out under redactContent. Streams get the same entry as a typed permission.decision event (PermissionDecisionEvent). This is additive and the schema stays v1. Nothing is evaluated or emitted unless permissions or onPermissionDecision is set, so existing runs and streams are unchanged.
  • Sub-agents inherit the parent's rules, checked before their own, and the parent's onPermissionDecision. This is in subagentRuntime.ts (InheritedRuntime) and delegation.ts (childOptions, SubagentSpec.permissions).
  • toolErrors.ts: new ToolErrorKind 'denied'.
  • Docs: a "Permission policies" section in docs/approvals.md with a type-checked snippet, a permission.decision row in docs/streaming.md, a denied row in docs/tools.md, the README approvals feature line, CHANGELOG (Added), and regenerated llms-full.txt.

Files outside the ticket's listed scope (forced)

  • src/execution/AgentExecutor.ts: 2 lines (import type { PermissionOptions } + export interface ExecuteOptions extends PermissionOptions). ExecuteOptions is declared there, so the acceptance criterion cannot be met without touching it. I tried module augmentation from permissions.ts first. tsup's bundled .d.ts keeps a dangling declare module './AgentExecutor', so consumers would not see the fields, and I dropped that approach.
  • src/execution/agentRun.ts: RunEventSink.permissionDecision() emits the stream event. Also, parseToolArguments moved to toolArgsValidation.ts (still re-exported from toolCallExecution.ts). Without the move, toolCallExecution → permissions → agentRun → toolCallExecution is an import cycle, which fallow rejects.
  • src/execution/index.ts (exports) and src/execution/agentEvents.test-d.ts (the exhaustive event-type test).

Why

Epic X matrix row "Permissions policy" is ⚠️. eve has approval policies and open-harness has one approve callback. We only had per-tool needsApproval predicates and the shell tool's own allow/deny lists. This adds one declarative, ordered policy for the whole agent, plus an audit trail.

Acceptance criteria

  • permissions?: PermissionRule[] on createAgent() and ExecuteOptions. Rules are checked in order before needsApproval and the first match decides: allow / deny (kind: 'denied' + reason, emits tool.error) / ask (approval pause or approve). No match keeps today's behaviour.
  • Helpers allow(tools), deny(tools, reason?), ask(tools) in src/execution/permissions.ts
  • 'denied' added to ToolErrorKind
  • onPermissionDecision audit callback (args omitted under redactContent, ISO at) plus a typed permission.decision event in agentEvents.ts (additive, schema v1)
  • Sub-agents inherit the parent's permissions through the subagent runtime (a few lines, done here, not deferred to X2.2)
  • Tests in src/execution/permissions.test.ts with mockModel: allow overrides needsApproval; deny reaches the model with its reason and emits the event; ask pauses (and the approve callback path); regex/array matching; async when; order precedence; audit entries (plus redaction, a throwing when, no events without a policy, sub-agent inheritance)
  • Docs: docs/approvals.md "Permission policies" (type-checked snippet), docs/streaming.md event row, README approvals line, llms regenerated, CHANGELOG (Added)

How verified

  • npx tsc --noEmit: no errors
  • npm run lint: ✖ 404 problems (0 errors, 404 warnings), the same count as main
  • npm run test:types: Test Files 6 passed (6), Tests 27 passed (27), Type Errors no errors
  • npx vitest run src/execution/permissions.test.ts: Tests 11 passed (11)
  • npx vitest run (after building): Tests 1 failed | 2234 passed | 3 skipped (2238). The one failure is src/deploy/adapters/cloudflare.test.ts > real wrangler tooling > wrangler dev serves the worker on the local workerd runtime (expected undefined to be 200). It needs a local workerd runtime, which this sandbox cannot provide. It fails again when run alone and is unrelated to this change. The create-loushy-agent tests pass once its package is built.
  • npm run build && npm run build --workspace=packages/create-loushy-agent: both succeed
  • npm run docs:verify-snippets -- --skip-build: all 116 snippet(s) type-check against src/; 6 also run cleanly
  • npm run docs:llms:check: passes
  • npm run test:coverage: the same single wrangler failure, so I re-ran with --coverage.reportOnFailure to write the coverage file. Then npm run fallow exits 0 with ✓ No issues found. Its one duplication group, AnthropicProvider/OpenAIProvider, was already there.

🤖 Generated with Claude Code

https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR


Generated by Claude Code

claude added 2 commits October 1, 2026 16:24
…dsApproval, with an audit log

`permissions?: PermissionRule[]` on createAgent(), ExecuteOptions and
resumeAfterApproval() options: ordered { tool, when?, action, reason? } rules
checked for every tool call after validation and preToolCall hooks, before
needsApproval. First match decides: allow runs without approval, deny gives
the model a `kind: 'denied'` tool error (ToolDeniedError, with reason) and
emits tool.error, ask pauses for approval (or the approve callback). No match
keeps today's behaviour. Helpers allow()/deny()/ask().

Audit log: onPermissionDecision(entry) per call and a typed
`permission.decision` stream event (schema stays v1); args omitted under
redactContent. Nothing is evaluated or emitted unless permissions or
onPermissionDecision is set. Sub-agents inherit the parent's rules (ahead
of their own) and its audit callback.

parseToolArguments moves to toolArgsValidation.ts (re-exported from
toolCallExecution.ts) so permissions.ts can reach the stream sink in
agentRun.ts without an import cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR
…icies

# Conflicts:
#	CHANGELOG.md
#	src/execution/agentEvents.test-d.ts
#	src/execution/agentRun.ts
@LinuxDevil
LinuxDevil merged commit bab8044 into main Oct 1, 2026
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.

2 participants