-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPermissionManager.java
More file actions
355 lines (333 loc) · 16.4 KB
/
Copy pathPermissionManager.java
File metadata and controls
355 lines (333 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
package dev.aceclaw.security;
import dev.aceclaw.security.audit.CapabilityAuditLog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Central permission manager that evaluates permission requests
* against the active policy and tracks session-level approvals.
*
* <p>Thread-safe: may be called from multiple virtual threads concurrently.
*
* <h3>Per-session "remember" scope (issue #456)</h3>
*
* <p>Earlier versions kept a single shared {@code Set<String>} of
* approved tool names for the entire daemon, which meant clicking
* "Always allow" in session A silently disabled the prompt for that
* tool in every other concurrent session — including sessions for
* unrelated workspaces. Fix: keyed by sessionId so each session's
* "remember" decision stays in its own scope. Sessions clear their
* entry on destroy via {@link #clearSessionApprovals(String)}.
*/
public final class PermissionManager {
private static final Logger log = LoggerFactory.getLogger(PermissionManager.class);
private final PermissionPolicy policy;
/**
* Per-session blanket approvals: {@code sessionId → toolName set}.
* Empty entries are pruned by {@link #clearSessionApprovals(String)}
* so a long-lived daemon doesn't leak state for ended sessions.
*/
private final Map<String, Set<String>> sessionApprovals = new ConcurrentHashMap<>();
/**
* Optional signed audit log of every decision (#465 Layer 8 v1).
* Null = audit disabled (default for unit tests that construct a
* manager without disk I/O setup). When non-null, every call to
* {@link #check} records one entry — best-effort, never throws.
*/
private final CapabilityAuditLog auditLog;
public PermissionManager(PermissionPolicy policy) {
this(policy, null);
}
/**
* Constructs a manager that signs and persists every decision to
* {@code auditLog}. Pass {@code null} to disable auditing (this is
* what the single-arg constructor does).
*/
public PermissionManager(PermissionPolicy policy, CapabilityAuditLog auditLog) {
this.policy = policy;
this.auditLog = auditLog;
}
/**
* Checks whether the given request is permitted within the given
* session. <strong>Legacy entry point</strong> retained for callers that
* still construct flat {@link PermissionRequest}s; under the hood it
* wraps the request as a {@link Capability.LegacyToolUse} and delegates
* to {@link #check(Capability, Provenance)}, so legacy and structured
* paths share one decision-and-audit pipeline (#480 Layer 1, PR 2).
*
* <p>Order is unchanged: session-level blanket approval → policy. The
* blanket-approval lookup is per-session — a tool approved in session A
* is NOT auto-approved in session B (issue #456).
*
* @param request the permission request
* @param sessionId the session this check belongs to. {@code null}
* skips the per-session allow-list lookup — daemon-
* internal checks (cron, boot scripts) use this.
* @return the decision
*/
public PermissionDecision check(PermissionRequest request, String sessionId) {
Objects.requireNonNull(request, "request");
var capability = new Capability.LegacyToolUse(request.toolName(), request.level());
var provenance = Provenance.fromNullableSessionId(sessionId);
// Legacy callers' allowlist key is already the tool name — pass it
// through unchanged so existing "always allow X" approvals stay valid.
return check(capability, provenance, request.toolName(), request.description());
}
/**
* Structured-capability entry point introduced by #480 PR 2. Takes a
* {@link Capability} (one of the sealed variants) plus the
* {@link Provenance} that records how the agent arrived at this check,
* and returns the same decision the legacy method does.
*
* <p>This convenience overload uses the capability's
* {@link Capability#allowlistKey() default allowlist key} (the variant
* class name) and {@link Capability#displayLabel()} as the prompt
* description. Tool dispatchers should prefer
* {@link #check(Capability, Provenance, String, String)} so existing
* tool-name-keyed allowlists keep working through migration and the
* user sees a richer prompt than the synthetic {@code displayLabel()}.
*/
public PermissionDecision check(Capability capability, Provenance provenance) {
Objects.requireNonNull(capability, "capability");
Objects.requireNonNull(provenance, "provenance");
return check(capability, provenance, capability.allowlistKey(), capability.displayLabel());
}
/**
* Full structured-capability entry point. Caller supplies an explicit
* {@code allowlistKey} (typically the originating tool's name) and a
* {@code description} (typically the dispatcher's rich human-readable
* tool summary). Used by the dispatcher in {@code StreamingAgentHandler}
* so that:
*
* <ul>
* <li>"Always allow {@code write_file}" approvals granted before #480
* keep auto-approving even after {@code WriteFileTool} migrates
* to {@code CapabilityAware} — the allowlist is keyed by tool
* name, not by capability variant.</li>
* <li>The user sees the same prompt for both legacy and migrated
* tools — no UX regression during migration.</li>
* </ul>
*
* <h4>Pipeline (#480 PR 4 / #495)</h4>
*
* <ol>
* <li><b>Structural denial</b> ({@link PermissionPolicy#evaluateStructural}):
* cross-cutting refusals like "never write to {@code .env}" run
* FIRST so they cannot be bypassed by a prior "always allow X"
* approval. Audited and returned immediately when matched.</li>
* <li><b>Session blanket approval</b>: if the originating tool has been
* blanket-approved in this session (per-session, #456), auto-approve
* and audit with the {@code session-blanket-approval} marker.</li>
* <li><b>Policy evaluation</b> ({@link PermissionPolicy#evaluate}):
* the structured {@link Capability} + {@link Provenance} +
* description go to the policy directly — no flat-record bridge.
* Result audited.</li>
* </ol>
*/
public PermissionDecision check(
Capability capability,
Provenance provenance,
String allowlistKey,
String description) {
Objects.requireNonNull(capability, "capability");
Objects.requireNonNull(provenance, "provenance");
Objects.requireNonNull(allowlistKey, "allowlistKey");
Objects.requireNonNull(description, "description");
// Structural denials (sensitive paths like .env, .ssh/, /etc/*) MUST
// fire BEFORE the session-blanket lookup. Otherwise a user who clicked
// "always allow write_file" earlier could route a FileWrite(.env) past
// the rule — defeating the "overrides all modes" invariant of the
// hard-denial layer (Codex P1 on #495).
var structural = policy.evaluateStructural(capability);
if (structural != null) {
log.debug("Permission denied (structural): key={}, reason={}",
allowlistKey, structural.reason());
audit(allowlistKey, capability, provenance, structural, null);
return structural;
}
String sessionIdOrNull = provenance.sessionId().map(s -> s.value()).orElse(null);
if (sessionIdOrNull != null) {
var allow = sessionApprovals.get(sessionIdOrNull);
if (allow != null && allow.contains(allowlistKey)) {
log.debug("Permission auto-approved (session blanket): key={}, sessionId={}",
allowlistKey, sessionIdOrNull);
var decision = new PermissionDecision.Approved();
audit(allowlistKey, capability, provenance, decision, "session-blanket-approval");
return decision;
}
}
// PolicyEngine now consumes the structured capability + provenance
// directly (#465 Scope #2 / #480 PR 4). The flat PermissionRequest
// bridge is gone — policies pattern-match on the sealed variant and
// can reach fields like FileWrite.path, HttpFetch.url, etc.
// `description` is the dispatcher's rich human-readable phrasing,
// forwarded so the policy can produce a user-facing prompt that
// matches the tool side rather than the variant's synthetic
// displayLabel.
var decision = policy.evaluate(capability, provenance, description);
log.debug("Permission check: key={}, level={}, sessionId={}, decision={}",
allowlistKey, capability.risk(), sessionIdOrNull,
decision.getClass().getSimpleName());
audit(allowlistKey, capability, provenance, decision, null);
return decision;
}
/**
* Writes one v2 audit entry — structured {@link Capability} and full
* {@link Provenance} (#480 PR 3). Flattens the sealed
* {@link PermissionDecision} into the on-disk string form
* ({@code APPROVED} / {@code DENIED} / {@code NEEDS_APPROVAL}) and
* chooses the {@code reason} field: caller-supplied note (for the
* session-blanket branch), the denial reason (for Denied), the prompt
* (for NeedsUserApproval), or null.
*
* <p>The on-disk record carries:
*
* <ul>
* <li>{@code allowlistKey} → {@code toolName} field — keeps v1 query
* tooling that filters by tool name working unchanged across the
* schema bump (e.g. "show me all bash decisions" still works).</li>
* <li>{@code capability.risk()} → {@code level} field — same reason.
* For migrated tools this matches the variant's structural risk
* (and reflects {@code BashExec}'s self-escalation to
* {@code DANGEROUS}); for legacy callers (the
* {@link #check(PermissionRequest, String)} shim wraps as
* {@link Capability.LegacyToolUse}) it carries the
* declared level.</li>
* <li>{@code capability} and {@code provenance} → typed JSON nested
* objects, so PolicyEngine and the dashboard timeline see paths,
* URLs, plan-step IDs, etc. directly without having to parse the
* human prompt.</li>
* </ul>
*/
private void audit(
String allowlistKey,
Capability capability,
Provenance provenance,
PermissionDecision decision,
String approvalNote) {
if (auditLog == null) return;
// Audit is best-effort by contract — a degraded log must not abort
// the agent. CapabilityAuditLog.appendEntry already swallows
// IOException internally, but the v2 path's signablePayload(...)
// can surface a serialisation IllegalStateException through here,
// and any other unchecked exception would propagate out of the
// permission check. Wrap the whole branch so audit failures
// produce a warning, not a turn-killing error.
try {
String kind;
String reason;
switch (decision) {
case PermissionDecision.Approved _ -> {
kind = "APPROVED";
reason = approvalNote;
}
case PermissionDecision.Denied d -> {
kind = "DENIED";
reason = d.reason();
}
case PermissionDecision.NeedsUserApproval n -> {
kind = "NEEDS_APPROVAL";
reason = n.prompt();
}
}
auditLog.record(Instant.now(), allowlistKey, capability, provenance, kind, reason);
} catch (RuntimeException auditFailure) {
log.warn("Audit log write failed for tool={}, level={}: {}",
allowlistKey, capability.risk(), auditFailure.getMessage());
}
}
/**
* Runs only the structural / system-invariant denial layer of the policy
* — no session-blanket lookup, no mode-based decision. Used by the
* sub-agent permission path (which previously consulted only the
* session-approval boolean, bypassing the structural rules entirely —
* Codex P1 on #495). Returns {@code null} when no structural rule
* applies; the caller then routes through whatever approval logic is
* appropriate for its context.
*
* <p>When the structural layer denies, an audit entry is written so
* sub-agent attempts to write {@code .env} / {@code .ssh/} / etc. are
* observable in the same on-disk record as main-dispatcher denials.
* Without this entry, a successful structural denial on the sub-agent
* path would be invisible to forensics — the caller (the sub-agent
* permission checker) can't audit because it doesn't depend on
* {@code aceclaw-security}'s audit types.
*
* <p>No double-audit risk on the main dispatcher path: that path runs
* {@code policy.evaluateStructural} directly inside
* {@link #check(Capability, Provenance, String, String)} (which has its
* own audit hook) and does NOT route through this method.
*
* @param capability the structured capability to test
* @param provenance audit context recorded when a denial is written.
* {@code null} is allowed for callers that have no
* session in scope; uses
* {@link Provenance#daemonInternal()} as a stand-in.
* @param allowlistKey audit context — the originating tool name, so
* {@code grep toolName=...} on the on-disk log
* keeps working uniformly across denial sources.
*/
public PermissionDecision.Denied checkStructural(
Capability capability,
Provenance provenance,
String allowlistKey) {
Objects.requireNonNull(capability, "capability");
var denial = policy.evaluateStructural(capability);
if (denial != null) {
var prov = provenance != null ? provenance : Provenance.daemonInternal();
String key = allowlistKey != null ? allowlistKey : capability.allowlistKey();
log.debug("Permission denied (structural, out-of-band): key={}, reason={}",
key, denial.reason());
audit(key, capability, prov, denial, null);
}
return denial;
}
/**
* Records a blanket session-level approval for a tool. After this
* call, future requests for this tool from THIS session are
* auto-approved (other sessions are unaffected — see #456).
*/
public void approveForSession(String sessionId, String toolName) {
Objects.requireNonNull(sessionId, "sessionId");
Objects.requireNonNull(toolName, "toolName");
sessionApprovals
.computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet())
.add(toolName);
log.info("Session-level approval granted: tool={}, sessionId={}",
toolName, sessionId);
}
/**
* Clears the allow-list for a single session. Intended to be wired
* to the daemon's session-destroy hook so allow-lists for ended
* sessions don't leak indefinitely.
*/
public void clearSessionApprovals(String sessionId) {
Objects.requireNonNull(sessionId, "sessionId");
var removed = sessionApprovals.remove(sessionId);
if (removed != null) {
log.debug("Session approvals cleared for sessionId={}", sessionId);
}
}
/**
* Clears every session's allow-list. Test/admin helper — use
* {@link #clearSessionApprovals(String)} for normal session
* teardown.
*/
public void clearAllSessionApprovals() {
sessionApprovals.clear();
log.debug("All session approvals cleared");
}
/**
* Returns whether the tool has blanket approval in the given session.
*/
public boolean hasSessionApproval(String sessionId, String toolName) {
Objects.requireNonNull(sessionId, "sessionId");
Objects.requireNonNull(toolName, "toolName");
var allow = sessionApprovals.get(sessionId);
return allow != null && allow.contains(toolName);
}
}