Design reference for the policy evaluator named in
../ARCHITECTURE.md and implemented by Epic 29-A
(ccsc-v1b) and Epic 29-B. This document fixes the evaluation semantics
before policy.ts is written, so 29-A PRs can be reviewed against a frozen
decision procedure.
A policy decides whether an MCP tool call proceeds, is denied, or requires a human approver. The evaluator is pure — given a tool call, a policy set, and the current time, it returns a decision with no side effects. Side effects (asking for approval, journaling, denying) happen outside.
interface ToolCall {
tool: string // "reply" | "upload_file" | …
input: Record<string, unknown> // validated args (after Zod at the MCP layer)
sessionKey: SessionKey // see 000-docs/session-state-machine.md
actor: Principal // 'session_owner' | 'claude_process'
// approvers come in via a later turn
}
type PolicyDecision =
| { kind: 'allow'; rule?: string } // ID of the matching rule, if any
| { kind: 'deny'; rule: string; reason: string }
| { kind: 'require'; rule: string; approver: 'human_approver'; ttlMs: number }
function evaluate(
call: ToolCall,
policies: PolicyRule[],
now: number, // epoch ms
): PolicyDecisionThe actor is the direct caller of the tool — almost always the Claude
process. Human approvals arrive as a later, separate turn (permission
reply) that flips a pending state; they are not inputs to evaluate().
const PolicyRule = z.discriminatedUnion('effect', [
z.object({
id: z.string().min(1),
effect: z.literal('auto_approve'),
match: MatchSpec,
priority: z.number().int().default(100),
}),
z.object({
id: z.string().min(1),
effect: z.literal('deny'),
match: MatchSpec,
reason: z.string().min(1),
priority: z.number().int().default(100),
}),
z.object({
id: z.string().min(1),
effect: z.literal('require_approval'),
match: MatchSpec,
ttlMs: z.number().int().positive().default(5 * 60 * 1000),
priority: z.number().int().default(100),
}),
])
const MatchSpec = z.object({
tool: z.string().optional(), // exact tool name
pathPrefix: z.string().optional(), // canonicalized via realpath before compare
channel: z.string().optional(), // Slack channel ID
thread_ts: z.string().optional(), // Slack thread timestamp — schema-only in v0.5.x; Epic 29-B wires into evaluate()
actor: z.enum(['session_owner', 'claude_process']).optional(),
argEquals: z.record(z.string(), z.unknown()).optional(),
}).refine(hasAtLeastOneField, 'match must constrain at least one field')Three effects, first-applicable combining (see below). priority tie-breaks
within effect when two rules would otherwise be equivalent.
The thread_ts predicate is present in the v1 schema so operators can author
thread-scoped rules against a stable shape; enforcement is deferred to Epic
29-B alongside evaluate() wiring.
Why no allow_any_of, allow_if_* forms? Every extra combinator is
another place for a shadow. The three-effect schema is deliberately narrow;
compound logic is expressed by authoring multiple rules.
The evaluator combines rules in two passes:
- Strictest-tier-wins across tiers. Tiers are evaluated in order
admin → user → workspace → default. For each tier, the evaluator walks rules in that tier in authored order (first-applicable). The first matching rule's effect is the decision; lower tiers are not consulted. - First-applicable within a tier. Within a single tier, the v0.5.x semantic still holds — the operator scans top-to-bottom, the first matching rule wins, no scoring or weighting.
This is the tier-aware form of the original first-applicable
algorithm. When no rule declares a tier, every rule lives in
'default' and the behavior collapses to the original single-tier
first-applicable. Backward compatibility is structural: existing
access.json files load and evaluate identically.
Why two passes:
| Property | Original | Tier-aware |
|---|---|---|
| Readability | top-to-bottom | top-to-bottom within tier; tier label is the section header |
| Asymmetric override | not expressible | admin auto_approve overrides workspace deny (intended); admin deny overrides workspace auto_approve (intended) |
| Footgun protection | shadow lint within authored order | shadow lint within authored order + cross-tier intersection lint (ccsc-4g8) for the silent-override case |
| Multi-operator deployment | re-author every rule per operator | declare provenance via tier; cohort/enterprise contexts decode naturally |
Why first-applicable within a tier:
- Readability under review. An operator authoring rules by hand can predict the outcome by reading top-to-bottom. No mental model of "specificity."
- Bypass is visible. If a later (stricter) rule is shadowed by an earlier (broader) one, the load-time linter surfaces the shadow as a warning. The operator sees both rules and can re-order.
- Monotonicity. Appending a new rule at the end can never weaken the policy — a later rule only fires when every earlier rule misses. New rules are safe edits.
Tier semantics:
'default'— un-tiered rules. Behaves identically to v0.5.x.'workspace'— rules attached to the Slack workspace (multi-channel scope).'user'— rules attached to a specific operator identity.'admin'— rules from the operator/security authority; overrides everything below.
The tiers are opaque to the engine beyond their ordinal precedence — the engine doesn't know what "admin" means socially, only that admin rules are consulted first. This keeps the model deployable in any context that wants override layering (cohort, enterprise, partner engagement).
The default when no rule matches is deny-by-omission for tools listed
in requireAuthoredPolicy, and allow for others. The default set
starts as ['upload_file']; it grows as dangerous tools are added.
An empty policy set would make the plugin unusable on first install. The
pragmatic split: tools whose only reason to exist is mutation of
external state (upload, post, delete) demand an authored rule; read-only
tools do not. The full list is in policy.ts next to the evaluator, not
in this doc (it changes with tool surface).
flowchart TD
Start([Tool call arrives]) --> Canon[Canonicalize match.pathPrefix<br/>via fs.realpathSync.native]
Canon --> Iter{For each rule<br/>in authored order}
Iter -->|Next rule| Match{match applies?}
Match -->|No| Iter
Match -->|Yes| Effect{Rule effect}
Effect -->|auto_approve| Allow[[Decision: allow]]
Effect -->|deny| Deny[[Decision: deny + reason]]
Effect -->|require_approval| Pending{Pending approval<br/>for this rule<br/>within ttlMs?}
Pending -->|Yes, fresh| Allow
Pending -->|No / expired| Require[[Decision: require + approver = human_approver]]
Iter -->|Exhausted| DefaultDeny{Tool in<br/>requireAuthoredPolicy?}
DefaultDeny -->|Yes| DenyMissing[[Decision: deny<br/>reason = no rule]]
DefaultDeny -->|No| AllowDefault[[Decision: allow<br/>rule = default]]
Allow --> Journal[Emit journal event:<br/>decision + rule ID + call hash]
Deny --> Journal
DenyMissing --> Journal
Require --> Journal
AllowDefault --> Journal
Journal --> End([Return PolicyDecision])
classDef decision fill:#dcfce7,stroke:#166534,color:#000
classDef denied fill:#fee2e2,stroke:#991b1b,color:#000
classDef pending fill:#fef3c7,stroke:#92400e,color:#000
class Allow,AllowDefault decision
class Deny,DenyMissing denied
class Require pending
Key properties of the diagram:
- Single pass. Each rule is evaluated at most once per call. No backtracking, no second pass.
- Side-effect-free until the end. The "journal event" node is emitted
after the decision is determined; the evaluator itself does not write
to any log. The caller (
server.ts) forwards the decision to the journal sink. - Pending-approval check is a read, not a write. A live approval for
(rule.id, sessionKey)withinttlMsturnsrequire_approvalintoallow. The approval record itself is written by the permission-reply handler, not byevaluate().
Path-prefix matches are canonicalized before compare:
call.input.pathis resolved to an absolute path viapath.resolve(process.cwd(), input.path).- The result is passed to
fs.realpathSync.native()— this resolves every symlink. rule.match.pathPrefixis resolved the same way (once, at load time, and cached).- Match is
resolvedInput.startsWith(resolvedPrefix + path.sep)orresolvedInput === resolvedPrefix. The+ path.sepprevents/etc/passwdmatching prefix/etc/pass.
Why realpath at load time for the prefix? If the operator writes a
rule against /var/log/app and /var/log is a symlink to /srv/logs,
the rule stays meaningful after a reorg only if we compare canonicalized
paths. The cost is that deleting or replacing a symlink after load
requires a reload. That tradeoff is spelled out in the operator docs.
CWE-22 mitigation. An attacker cannot smuggle a path through a symlink
because the match compares resolved paths. A rule.match.pathPrefix of
/safe/dir will never match call.input.path = "/safe/dir/../../etc/passwd"
— realpath collapses it to /etc/passwd and the prefix check fails.
At load time (before the evaluator is ever called), a static linter scans the policy list for shadows:
rule R_later is shadowed by R_earlier
when every call that matches R_later also matches R_earlier
The linter uses a conservative subset-check over MatchSpec fields:
tool: R_earlier.tool is undefined OR R_earlier.tool === R_later.toolpathPrefix: R_earlier.pathPrefix is a prefix of R_later.pathPrefix (both canonicalized)channel: same rule astoolactor: same rule astoolargEquals: R_earlier.argEquals is a subset of R_later.argEquals
If every field on R_earlier is less-specific-or-equal to the same field
on R_later, then R_later is shadowed and emits a warning:
policy-load: warning: rule 'deny-upload-env' (line 42) is shadowed by
earlier rule 'auto-approve-all-uploads' (line 12) —
reorder or narrow the earlier rule.
Warnings do not block startup; they go to stderr and the audit log. Operators see them during local development and in CI. Fail-closed is reserved for monotonicity violations.
The within-tier subset check above catches the classic "first rule
swallows the second" shadow. It cannot, by construction, catch a
second class that appears once MatchSpec.tier lands: two rules
in different tiers whose matches share a concrete call.
cross-tier shadow:
R_lower (tier != admin, effect = auto_approve)
R_admin (tier = admin, effect = deny)
∃ a concrete tool call C such that match(R_lower, C) ∧ match(R_admin, C)
The Workspace auto_approve and Admin deny aren't in a subset
relationship — each constrains different fields. But they do intersect
on Bash + C001 + session_owner. Once ccsc-8pw lands and evaluate()
acts on tier precedence, the Admin deny wins — but the audit trail (the
policy file as humans read it) makes the conflict look like the
Workspace rule is in effect. The lint surfaces the intersection so the
operator must resolve the ambiguity explicitly: narrow the lower-tier
match, retier the rule, or remove one of the two.
The check is asymmetric. Only the dangerous direction is flagged:
a lower-tier auto_approve intersecting a higher-tier deny. The
reverse — an Admin auto_approve intersecting a Workspace deny —
is the intended tier semantics ("Admin overrides this") and is not
warned.
Intersection logic (matchesIntersect in policy.ts):
| Field | Both unset | One unset, one set | Both set |
|---|---|---|---|
tool |
intersect | intersect | must be equal |
channel |
intersect | intersect | must be equal |
thread_ts |
intersect | intersect | must be equal |
actor |
intersect | intersect | must be equal |
pathPrefix |
intersect | intersect | one must prefix the other |
argEquals |
intersect | intersect | shared keys must agree; disjoint keys ignored |
tier itself is excluded from intersection — it's provenance, not
call-space identity. Two rules in different tiers CAN match the same
call; that's the whole reason cross-tier shadow exists as a category.
The ShadowWarning shape gains a crossTier: boolean field:
crossTier: false— classic within-tier subset shadow (existing behavior, backward-compatible default).crossTier: true— cross-tier intersection between a non-Adminauto_approveand an Admindeny.
Both kinds emit the same warning-not-block discipline.
Across hot reloads, appending rules must never weaken policy for any call. The check, run at load time against the previous policy hash:
for every rule R newly added in this load:
if R.effect == 'auto_approve':
confirm there is no earlier existing rule with effect 'deny' whose
match is a superset of R.match.
If the check fails, the server refuses to adopt the new policy set and keeps the previous one. The failure is logged and surfaces as a bead for operator review. Fail-closed because a weakening reload is the exact shape of an attack via operator coercion.
Removed or modified rules are not checked — removing a deny does weaken policy, and the operator should know that when they author the removal. The check catches only accidental weakening from additions.
Gemini CLI's policy engine (docs/reference/policy-engine.md) documents a stronger information-flow boundary than first-applicable alone: when policy denies a tool call, the denied call is excised from the agent's context window so the model cannot rephrase the call to dodge the rule on subsequent turns. CCSC adopts the spirit of this pattern, adapted for its MCP-bridge architecture.
| Layer | Owner | Can CCSC excise? |
|---|---|---|
| Claude's conversation history (KV cache) | Claude Code | No — out of bridge's scope |
| The original tool-call request | Claude Code | No — Claude itself made the call; it's already in its context |
| The MCP notification response Claude observes | CCSC server | Yes — this is the surface ccsc-06s minimises |
| The Slack message Claude sees on inbound | CCSC bridge | Already filtered — bot-self-echo gate (T3) drops the bridge's own denial post |
| The audit journal | CCSC server | Never — full forensic detail is preserved |
The implementable minimisation: make sure nothing in the response Claude observes carries retry-aiding metadata. The full denial detail goes to the journal (forensic record) and the user-visible Slack post (operator visibility, bot-self-echo blocks Claude from reading it back).
On every policy.deny decision the dispatcher writes:
policy.deny— full detail:input,ruleId,reason,sessionKey,toolName,actorpolicy.deny.context_stripped— sametoolName+sessionKeyfor correlation, but noinput,ruleId, orreason. The distinction is intentional: the second event is the audit signal that minimisation was applied, NOT a second copy of the forensic record. An audit reader who replays the chain sees both events and knows (a) the call was denied with this rule + reason and (b) the denial response delivered to Claude was the minimal form.
Order is enforced by await (see recordPolicyDenyToJournal in
policy-dispatch.ts) — the second event is never written before the
first.
The notification body delivered to Claude is built via
buildDenyNotificationParams in policy-dispatch.ts. It produces
exactly two keys:
{ request_id: '<correlator>', behavior: 'deny' }No rule_id. No reason. No input echo. The literal-type
behavior: 'deny' pins the wire-format invariant statically;
TypeScript refuses any shape with extra keys at the call site.
The helper is intentionally trivial — its existence is the regression
protection. A future "helpful" addition that wants to include a
denial reason in the notification has to either edit the helper
(visible diff, design-doc PR) or call mcp.notification directly
(visible in code review).
The bridge cannot reach into Claude Code's process memory or rewrite its prompt cache. Claude already saw the original tool call when it made it — the denial response is the only surface the bridge controls. The narrower "minimise the response so it carries nothing the model can chain off of" is what ships; this is documented explicitly so a future reader of the design doc doesn't expect the stronger property.
A future Anthropic feature (e.g., anthropics/claude-code#53049 —
external IPC into mid-turn context) could enable full excision. Until
then, response minimisation is the floor.
require_approval decisions flow through the existing permission-reply
machinery:
sequenceDiagram
autonumber
participant CC as Claude process
participant PE as Policy evaluator
participant SRV as server.ts
participant SL as Slack (HA)
participant JS as Journal sink
CC->>SRV: tool call (upload_file, path=/tmp/x)
SRV->>PE: evaluate(call, rules, now)
PE-->>SRV: require_approval(rule=R42, ttl=5m)
SRV->>JS: log(require, R42, callHash)
SRV->>SL: ask HA "approve R42 for /tmp/x? [y/n CODE]"
SL-->>SRV: reply "y CODE"
Note over SRV: PERMISSION_REPLY_RE matches at inbound gate<br/>BEFORE reaching Claude
SRV->>SRV: record approval(R42, sessionKey, ttlExpires=now+5m)
SRV->>PE: evaluate(same call, rules, now+delta)
PE-->>SRV: allow(R42)
SRV->>JS: log(allow, R42, callHash)
SRV->>CC: tool result (proceed)
The approval is scoped to (rule.id, sessionKey) — a different session
in the same channel does not inherit the approval. The TTL starts at the
moment of approval, not the moment of request.
- Inbound gate runs before the evaluator. A denied inbound never
reaches
evaluate(). Gate handles identity; evaluator handles action. - Outbound gate runs after the evaluator. Even an allowed tool call
must still pass
assertOutboundAllowed()/assertSendable(). The evaluator is additional authorization, never sole authorization. - Journal sink receives every decision with the call hash and the rule ID. The journal is the audit trail for "why was this allowed?"
- Session boundary supplies
sessionKey. The evaluator does not read or write session files.
The evaluator is never the only thing between an attacker and an action. It is a veto layer, not the sole gate.
- Not a capability system. Rules match on surface attributes (tool name, path prefix, channel, args). There is no delegation, no revocation ledger, no principal-to-principal capability passing.
- Not a time-of-check-to-time-of-use (TOCTTOU) solution. Path
canonicalization at evaluate time is the honest mitigation; a race
between
evaluate()and the tool's actualfs.open()is not closed by policy. Tools that care (file upload) pass the canonicalized path straight through toopen()with no re-resolution. - Not hot-reloaded on every config change. Reload is explicit via
SIGHUP or
/slack-channel:accesssubcommand, so the monotonicity check runs at a known moment. - Not a DSL. Rules are plain JSON, Zod-validated. No expressions, no conditionals, no functions. Add rules, not features.
Every 29-A PR is checked against these.
evaluate()has no side effects. Callers emit journal events.- First-applicable combining; rule list order is the decision procedure.
- Path matching canonicalizes both sides via
realpathSync.native. - Shadow-detection warns but does not block.
- Monotonicity violations at load time fail closed.
- Approvals are scoped to
(rule.id, sessionKey), never workspace-wide. - Default is allow unless the tool is in
requireAuthoredPolicy. - The evaluator is never the sole gate; outbound gate runs after.
Concrete policy sets that exercise the combining algorithm. Every
example is a legal input to parsePolicyRules and produces the
decisions shown. Operators can paste these into a test harness to
verify local changes behave as expected.
The smallest policy set that changes default behavior. reply is not
in requireAuthoredPolicy, so the authored rule is a no-op for it
(default already allows); it's listed here to make the policy
self-documenting for future readers.
[
{ "id": "allow-replies", "effect": "auto_approve", "match": { "tool": "reply" } },
{ "id": "gate-uploads", "effect": "require_approval", "match": { "tool": "upload_file" }, "ttlMs": 300000 }
]| Call | Decision |
|---|---|
tool: reply |
{ kind: 'allow', rule: 'allow-replies' } |
tool: upload_file, no prior approval |
{ kind: 'require', rule: 'gate-uploads', approver: 'human_approver' } |
tool: upload_file, approval in flight |
{ kind: 'allow', rule: 'gate-uploads' } |
tool: edit_message |
{ kind: 'allow' } (default branch) |
Demonstrates first-applicable ordering + CWE-22 mitigation. A traversal
/safe/../etc/passwd realpath-resolves to /etc/passwd and falls
through to the deny rule — the "looks safe" path doesn't bypass the
guard.
[
{ "id": "deny-etc", "effect": "deny",
"match": { "tool": "upload_file", "pathPrefix": "/etc" },
"reason": "uploads from /etc are never permitted" },
{ "id": "allow-safe-dir", "effect": "auto_approve",
"match": { "tool": "upload_file", "pathPrefix": "/safe" } }
]| Call | Decision |
|---|---|
upload_file path=/safe/report.pdf |
{ kind: 'allow', rule: 'allow-safe-dir' } |
upload_file path=/etc/passwd |
{ kind: 'deny', rule: 'deny-etc', reason: '…' } |
upload_file path=/safe/../etc/passwd (traversal) |
{ kind: 'deny', rule: 'deny-etc', reason: '…' } — realpath wins |
upload_file path=/other/x.pdf |
{ kind: 'deny', rule: 'default', reason: 'no policy authored…' } |
Incident channel wants hands-off auto-approval for the on-call Claude
process; other channels fall through to default. Shows how channel
and actor narrow the match.
[
{ "id": "incidents-autoreply",
"effect": "auto_approve",
"match": { "tool": "reply", "channel": "C_INCIDENTS", "actor": "claude_process" } }
]| Call | Decision |
|---|---|
reply in C_INCIDENTS, actor=claude_process |
{ kind: 'allow', rule: 'incidents-autoreply' } |
reply in C_INCIDENTS, actor=session_owner |
{ kind: 'allow' } (default — actor mismatch) |
reply in C_OTHER_CHANNEL |
{ kind: 'allow' } (default — channel mismatch) |
upload_file in C_INCIDENTS |
{ kind: 'deny', rule: 'default' } (tool mismatch → default deny) |
This policy set produces a warning but still boots. The linter's job is to point out the dead rule so the operator can reorder or narrow.
[
{ "id": "auto-approve-all-uploads", "effect": "auto_approve", "match": { "tool": "upload_file" } },
{ "id": "deny-env-upload", "effect": "deny",
"match": { "tool": "upload_file", "pathPrefix": "/etc" },
"reason": "blocks env files" }
]At load, detectShadowing() returns:
[
{ later: 'deny-env-upload',
earlier: 'auto-approve-all-uploads',
message: "rule 'deny-env-upload' is shadowed by earlier rule 'auto-approve-all-uploads' …" }
]
Every call to upload_file matches the earlier auto_approve first,
so deny-env-upload never fires. The fix is to reorder — put the deny
before the allow — or to narrow the allow to a specific pathPrefix.
Operator edits the policy file and adds an auto-approve that would
weaken an existing deny. Reload refuses; prev stays active.
// prev (currently loaded)
[{ "id": "deny-uploads", "effect": "deny", "match": { "tool": "upload_file" }, "reason": "default-off" }]
// next (attempted reload)
[
{ "id": "deny-uploads", "effect": "deny", "match": { "tool": "upload_file" }, "reason": "default-off" },
{ "id": "allow-pdfs", "effect": "auto_approve",
"match": { "tool": "upload_file", "argEquals": { "mimeType": "application/pdf" } } }
]checkMonotonicity(prev, next) returns:
[
{ newRule: 'allow-pdfs',
existingDeny: 'deny-uploads',
message: "new auto_approve rule 'allow-pdfs' weakens existing deny 'deny-uploads' — reload refused" }
]
The operator has two clean paths forward:
- Remove the old deny first. A reload that removes a deny is allowed — the operator has signed off by deleting the rule. Then reload the new auto-approve separately.
- Make the new auto-approve orthogonal. If the intent was to allow a narrower case and keep the deny as a catch-all, the rules need to be authored so the allow is strictly narrower and placed earlier (first-applicable will match the allow first). The monotonicity check flags the current pair because the new rule's match is a subset of the deny's match — they are not orthogonal.
- OASIS (2013). eXtensible Access Control Markup Language (XACML) Version 3.0. — first-applicable combining semantics.
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal") — path canonicalization mitigation.
- Saltzer & Schroeder (1975). The Protection of Information in Computer Systems. — least-privilege / complete-mediation framing of the evaluator-as-veto-layer role.
../ARCHITECTURE.md— policy evaluator component.../000-docs/THREAT-MODEL.md— T1 (prompt injection) and T7 (permission-reply forgery) mitigations.../000-docs/session-state-machine.md—sessionKeyprovenance.- Bead ccsc-nlr — this document. Blocks Epic 29-A (ccsc-v1b).
- Epic 29-A (ccsc-v1b.1 – ccsc-v1b.6) — implementation beads.