Skip to content

Latest commit

 

History

History
664 lines (523 loc) · 27.7 KB

File metadata and controls

664 lines (523 loc) · 27.7 KB

Policy Evaluation Flow

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.


Inputs and outputs

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
): PolicyDecision

The 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().


PolicyRule schema (Zod)

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.


Combining algorithm: strictest-tier-wins, then first-applicable (ccsc-8pw)

The evaluator combines rules in two passes:

  1. 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.
  2. 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:

  1. Readability under review. An operator authoring rules by hand can predict the outcome by reading top-to-bottom. No mental model of "specificity."
  2. 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.
  3. 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.

Why not deny-by-default for all?

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).


Evaluation flow

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
Loading

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) within ttlMs turns require_approval into allow. The approval record itself is written by the permission-reply handler, not by evaluate().

Path matching

Path-prefix matches are canonicalized before compare:

  1. call.input.path is resolved to an absolute path via path.resolve(process.cwd(), input.path).
  2. The result is passed to fs.realpathSync.native() — this resolves every symlink.
  3. rule.match.pathPrefix is resolved the same way (once, at load time, and cached).
  4. Match is resolvedInput.startsWith(resolvedPrefix + path.sep) or resolvedInput === resolvedPrefix. The + path.sep prevents /etc/passwd matching 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.


Shadow-detection linter

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.tool
  • pathPrefix: R_earlier.pathPrefix is a prefix of R_later.pathPrefix (both canonicalized)
  • channel: same rule as tool
  • actor: same rule as tool
  • argEquals: 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.

Cross-tier intersection (ccsc-4g8)

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-Admin auto_approve and an Admin deny.

Both kinds emit the same warning-not-block discipline.


Monotonicity invariant

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.


Context-stripping on policy.deny (ccsc-06s)

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.

What CCSC can and cannot excise

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).

Two-event journal sequence

On every policy.deny decision the dispatcher writes:

  1. policy.deny — full detail: input, ruleId, reason, sessionKey, toolName, actor
  2. policy.deny.context_stripped — same toolName + sessionKey for correlation, but no input, ruleId, or reason. 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.

Minimal MCP notification

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).

Why not full excision?

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.


Approval flow integration

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)
Loading

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.


Relationship to other subsystems

  • 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.


Non-goals

  • 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 actual fs.open() is not closed by policy. Tools that care (file upload) pass the canonicalized path straight through to open() with no re-resolution.
  • Not hot-reloaded on every config change. Reload is explicit via SIGHUP or /slack-channel:access subcommand, 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.

Invariants

Every 29-A PR is checked against these.

  1. evaluate() has no side effects. Callers emit journal events.
  2. First-applicable combining; rule list order is the decision procedure.
  3. Path matching canonicalizes both sides via realpathSync.native.
  4. Shadow-detection warns but does not block.
  5. Monotonicity violations at load time fail closed.
  6. Approvals are scoped to (rule.id, sessionKey), never workspace-wide.
  7. Default is allow unless the tool is in requireAuthoredPolicy.
  8. The evaluator is never the sole gate; outbound gate runs after.

Worked examples

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.

Example 1 — Allow replies, gate every upload

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)

Example 2 — Path-scoped upload: auto-approve inside /safe/, deny /etc/

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…' }

Example 3 — Channel + actor scoping for incident response

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)

Example 4 — Shadow warning (operator-visible at load)

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.

Example 5 — Monotonicity violation (load refused)

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:

  1. 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.
  2. 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.

References

  • 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.mdsessionKey provenance.
  • Bead ccsc-nlr — this document. Blocks Epic 29-A (ccsc-v1b).
  • Epic 29-A (ccsc-v1b.1 – ccsc-v1b.6) — implementation beads.