Skip to content
Prev Previous commit
Next Next commit
refactor(security): rename Provenance.legacy and extract ToolPermissi…
…onRouter

Provenance.legacy → fromNullableSessionId. The previous name implied legacy-only callers, but the dispatcher uses it too (PR 3 will fill in the chain). The new name describes what callers actually have — a possibly-null raw String id — without diluting the migration grep signal.

ToolPermissionRouter extracts the structured-vs-legacy permission branching from StreamingAgentHandler so the contract is testable without standing up the agent loop. New ToolPermissionRouterTest pins all six branches: structured path, legacy path, toCapability throw → fallback, toCapability null → fail-fast, policy errors propagate, null sessionId works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Loading branch information
xinhuagu and claude committed May 3, 2026
commit b622560cbadd9588e55f4dbebbab8642575c0403
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,10 @@
import dev.aceclaw.memory.DailyJournal;
import dev.aceclaw.memory.MarkdownMemoryStore;
import dev.aceclaw.memory.MemoryEntry;
import dev.aceclaw.security.Capability;
import dev.aceclaw.security.CapabilityAware;
import dev.aceclaw.security.PermissionDecision;
import dev.aceclaw.security.PermissionLevel;
import dev.aceclaw.security.PermissionManager;
import dev.aceclaw.security.PermissionRequest;
import dev.aceclaw.security.Provenance;
import dev.aceclaw.tools.AppleScriptTool;
import dev.aceclaw.tools.BashExecTool;
import dev.aceclaw.tools.EditFileTool;
Expand Down Expand Up @@ -3983,54 +3980,14 @@ public ToolResult execute(String inputJson) throws Exception {
var toolDescription = buildToolDescription(delegate.name(), effectiveInputJson);
final String finalInputJson = effectiveInputJson;

// #480 PR 2: tools that implement CapabilityAware advertise a
// structured Capability so PermissionManager / audit log see the
// real intent (e.g. FileWrite("/tmp/x", OVERWRITE)) rather than
// the flat tool name. The allowlist stays keyed by tool name
// (delegate.name()) so existing "always allow X" approvals keep
// working through migration; the rich human description carries
// through to the user's prompt unchanged. Falls back to the
// legacy PermissionRequest path for tools that haven't migrated
// yet — single audit/decision pipeline either way.
PermissionDecision decision;
if (delegate instanceof CapabilityAware capAware) {
// Three outcomes from the capability conversion, each
// handled distinctly:
// - returns a capability → take the structured path
// - throws on bad args → fall back to legacy (logged)
// - returns null → contract violation, fail fast
// The fallback is deliberately narrow. Errors from
// permissionManager.check itself (policy / audit) MUST
// surface — silently re-running the legacy path on those
// would mask real bugs and could downgrade the decision
// pipeline. (Codex + CodeRabbit reviews on #482.)
Capability capability;
boolean conversionThrew = false;
try {
capability = capAware.toCapability(objectMapper.readTree(finalInputJson));
} catch (RuntimeException | java.io.IOException toCapErr) {
log.warn("CapabilityAware tool {} rejected args; falling back to legacy permission path: {}",
delegate.name(), toCapErr.getMessage());
capability = null;
conversionThrew = true;
}
if (capability == null && !conversionThrew) {
throw new IllegalStateException(
"CapabilityAware tool " + delegate.name()
+ " returned null capability (contract violation)");
}
if (capability != null) {
var provenance = Provenance.legacy(sessionId);
decision = permissionManager.check(
capability, provenance, delegate.name(), toolDescription);
} else {
var permRequest = new PermissionRequest(delegate.name(), toolDescription, level);
decision = permissionManager.check(permRequest, sessionId);
}
} else {
var permRequest = new PermissionRequest(delegate.name(), toolDescription, level);
decision = permissionManager.check(permRequest, sessionId);
}
// #480 PR 2: routing the call through ToolPermissionRouter keeps
// the structured-vs-legacy branching logic in one testable place.
// CapabilityAware tools take the structured path; everything else
// hits the legacy PermissionRequest entry point. Both ultimately
// share PermissionManager's single decision/audit pipeline.
var decision = ToolPermissionRouter.check(
delegate, finalInputJson, sessionId, toolDescription, level,
permissionManager, objectMapper);
var overrideStatus = antiPatternOverrideSupplier != null
? antiPatternOverrideSupplier.get()
: new AntiPatternGateOverrideStatus(sessionId, delegate.name(), false, 0L, "");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package dev.aceclaw.daemon;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.aceclaw.core.agent.Tool;
import dev.aceclaw.security.Capability;
import dev.aceclaw.security.CapabilityAware;
import dev.aceclaw.security.PermissionDecision;
import dev.aceclaw.security.PermissionLevel;
import dev.aceclaw.security.PermissionManager;
import dev.aceclaw.security.PermissionRequest;
import dev.aceclaw.security.Provenance;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Objects;

/**
* Picks the right entry point on {@link PermissionManager} for a given tool
* call (#480 PR 2). Extracted from {@link StreamingAgentHandler} so the
* branching contract — "tools that implement {@link CapabilityAware} take
* the structured path; everything else takes legacy" — is testable without
* standing up the full agent loop.
*
* <h3>Three outcomes for a {@link CapabilityAware} tool</h3>
*
* <ol>
* <li>{@code toCapability(...)} returns a non-null {@link Capability} →
* call {@link PermissionManager#check(Capability, Provenance, String, String)}.
* The originating tool's name is the allowlist key (so historical
* "always allow {@code write_file}" approvals keep applying), and the
* caller-supplied human description carries through to the user prompt.</li>
* <li>{@code toCapability(...)} throws {@link RuntimeException} or
* {@link IOException} (parse failure, bad args) → log a warning and
* fall back to the legacy {@link PermissionRequest} path so the user
* still gets a meaningful approval prompt instead of an opaque crash.</li>
* <li>{@code toCapability(...)} returns {@code null} → contract violation;
* throw {@link IllegalStateException}. Silently downgrading to legacy
* here would mask a broken migration and drop structured policy/audit
* data. (Codex + CodeRabbit reviews on #482.)</li>
* </ol>
*
* <h3>What does NOT trigger the fallback</h3>
*
* Errors raised by {@code permissionManager.check} itself (policy / audit /
* runtime) are propagated as-is. Re-running the legacy path on those would
* mask real bugs and could change the decision pipeline behind the user's
* back. The fallback is strictly for the args-to-Capability conversion step.
*/
final class ToolPermissionRouter {

private static final Logger log = LoggerFactory.getLogger(ToolPermissionRouter.class);

private ToolPermissionRouter() { /* static-only */ }

/**
* Routes a tool call to the structured or legacy permission entry point.
*
* @param delegate the tool being invoked (may or may not be {@link CapabilityAware})
* @param inputJson the JSON args the LLM supplied to the tool
* @param sessionId the owning session id, or {@code null} for daemon-internal calls
* @param description rich human-readable description for the approval prompt
* @param fallbackLevel risk level used when going through the legacy path
* @param permissionManager the manager that evaluates policy
* @param mapper Jackson mapper, used to parse {@code inputJson} for capability conversion
* @return the manager's decision
* @throws IllegalStateException if a {@link CapabilityAware} tool's
* {@code toCapability(...)} returns {@code null} (contract violation)
*/
static PermissionDecision check(
Tool delegate,
String inputJson,
String sessionId,
String description,
PermissionLevel fallbackLevel,
PermissionManager permissionManager,
ObjectMapper mapper) {
Objects.requireNonNull(delegate, "delegate");
Objects.requireNonNull(inputJson, "inputJson");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow null tool args in permission routing

ToolPermissionRouter.check now hard-fails with Objects.requireNonNull(inputJson), but the surrounding execution path intentionally tolerates missing/invalid tool args (e.g., PermissionAwareTool.execute catches parse errors and continues, and ContentBlock.ToolUse can carry nullable input). With this guard, any tool call that arrives without an arguments payload throws an NPE before permission evaluation or legacy fallback, turning a previously recoverable flow into an unhandled failure. Please treat null args as an empty object (or route directly to legacy) instead of rejecting at the router boundary.

Useful? React with 👍 / 👎.

Objects.requireNonNull(description, "description");
Objects.requireNonNull(fallbackLevel, "fallbackLevel");
Objects.requireNonNull(permissionManager, "permissionManager");
Objects.requireNonNull(mapper, "mapper");

if (!(delegate instanceof CapabilityAware capAware)) {
var legacyRequest = new PermissionRequest(delegate.name(), description, fallbackLevel);
return permissionManager.check(legacyRequest, sessionId);
}

Capability capability;
boolean conversionThrew = false;
try {
capability = capAware.toCapability(mapper.readTree(inputJson));
} catch (RuntimeException | IOException toCapErr) {
log.warn("CapabilityAware tool {} rejected args; falling back to legacy permission path: {}",
delegate.name(), toCapErr.getMessage());
capability = null;
conversionThrew = true;
}
if (capability == null && !conversionThrew) {
throw new IllegalStateException(
"CapabilityAware tool " + delegate.name()
+ " returned null capability (contract violation)");
}
if (capability != null) {
var provenance = Provenance.fromNullableSessionId(sessionId);
return permissionManager.check(capability, provenance, delegate.name(), description);
}
var legacyRequest = new PermissionRequest(delegate.name(), description, fallbackLevel);
return permissionManager.check(legacyRequest, sessionId);
}
}
Loading
Loading