Chapter 7: Block overreach with policy
At the end of Chapter 6 your agent deleted an issue and the request succeeded, because nothing in Keycard refuses anything yet. The scope on each exchange is recorded and evaluated, but Keycard has no opinionated policy, so a request for write succeeds. So would a request for admin, or a scope that doesn’t even exist. Try it sometime: with no policy in the way, the exchange happily returns a credential scoped to whatever was requested.
In this chapter, you’ll write a policy in the Cedar policy language, activate it, and re-run the two tool calls from Chapter 6. Escalation will still work, but delete gets denied before credential issuance. The resulting error names the policy, and the audit log clearly shows what happened. There are no code changes in this chapter; you finished building the support-escalation MCP server in Chapter 6.
Where policy operates
Section titled “Where policy operates”Keycard policy gates credential issuance, the moment your MCP server asks Keycard to exchange the caller’s token for a Linear credential. It isn’t between your MCP server and Linear’s API endpoint, and it doesn’t parse mutations. Policy reads your tool request. This is why the token exchange is where enforcement takes place. It’s also why the hard-coded scope request in each tool function (read, issues:create, write) matters for greater access control.
Forbid vs. Permit in Keycard
Section titled “Forbid vs. Permit in Keycard”If no policies are activated in Keycard, every action by every actor is denied by default. The platform therefore ships with a default policy set activated that permits apps to access resources on behalf of users. Before writing a policy, go look at the currently active default policies.
-
In console.keycard.ai, open Policies. The Policy Sets tab shows one set marked Active Policy Set:
default-zone-policies. -
Click on the policy set. There are three small platform-managed policies:
default-app-delegation: permits an applicationwhen { context.on_behalf == true }. This permits any token exchange made on behalf of an authenticated user, for any resource, with any scopes.default-app-direct-access: permits an application whenprincipal.dependencies.contains(resource). This permits direct access to resources that are in an application’s dependency list.default-user-grants:permit (principal is Keycard::User, action, resource);Users who have granted consent can access resources.
When delete_issue asks for write, the delegation permit fires (your app, acting for you, on behalf is true) and Keycard issues the credential. The default policies don’t look at scopes. The defaults are grants; whatever was requested gets issued. If you want governance, you must write it.
Forbid overrides permit
Section titled “Forbid overrides permit”Cedar allows a request if any permit matches it, and permits can’t subtract. If you write a policy that permits only issues:create and read scopes, a write request still succeeds because the broad permit afforded by default-app-delegation still matches. Your policy just agrees extra hard about requests that were already allowed, and has nothing to say about the ones you want to deny.
On the other hand, forbid always wins: one matching forbid overrides every matching permit. The policy you’ll write is forbid, unless. You’ll refuse any Linear exchange whose requested scopes are not in the list of allowed scopes.
Write the policy
Section titled “Write the policy”-
Go to Policies → All policies → Create policy.
-
Name the new policy:
limit-linear-scopesand click Create.
You can fill in Describe what this policy does… with the following description:
Forbid all Linear API scopes except read and issues:create -
Switch the editor from Visual to Cedar and paste:
@id("limit-linear-scopes")forbid (principal, action, resource)when{(resource has identifier) &&((resource.identifier) == "https://api.linear.app")}unless{(context has scopes) &&(["read", "issues:create"].containsAll(context.scopes))}; -
Click Validate. The console checks the Cedar against Keycard’s policy schema. Saving is disabled until your policy passes validation. After validating, click Publish Policy.
This policy forbids Linear exchanges unless every requested scope is in the allowlist. It only applies to the Linear resource, leaving access to other resources unrestricted. Remember back in Chapter 2 when you didn’t include the write scope in metadata? As you’ve seen, the agent uses it anyway. Now policy enforcement is about to prevent that.
Create a new policy set
Section titled “Create a new policy set”Policies run as part of a policy set. Only one policy set can be active per Keycard. Your new set needs the three default policies along with your limit-linear-scopes. Otherwise, every other request gets denied.
-
Go to Policies → Policy Sets → New Policy Set. In the field that says Policy set name, name your policy:
workshop-policy-limit-linear-access -
Click Add policy… and add all four policies:
limit-linear-scopesplus the three defaults (default-app-delegation,default-app-direct-access,default-user-grants). -
Test the policy set. On the right in the Run Test section, select:
Actor:
Keycard Workshop MCP ClientAction: any (not editable)
Resource:
Linear APIUser context: toggle to enabled
Subject: select your user
Scopes:
read,issues:createLeave the remaining fields blank, and click Test. You should see a green Allow result.
-
Now delete
readandissues:createscopes. Addwriteinstead, and click Test. You should see a red Deny result. -
Click Publish as Candidate, then click again to confirm. Policy sets are versioned and immutable once published; the candidate is version 1 of your new policy set.
-
After publishing, there will be an Activate button in the top right. Activate the set and then confirm. Your policy set is now active (and the previous default set is deactivated).
Run the prompt
Section titled “Run the prompt”If your MCP server isn’t still running, run npm run dev and then repeat the Chapter 0 prompt:
Using the support-escalation tools, list the open support tickets. Then escalate the critical payment ticket (the one about double charges) to engineering with escalate_ticket.
Open the created Linear issue and verify its contents are still what you expect (the same as Chapter 6). The new policy only acts on the token exchanges with the Linear API resource. The Linear requests for read and issues:create both had scopes within the allowlist, to the token exchange succeeded and the issue was created.
Next, repeat the delete prompt:
Delete the Linear issue you just created with delete_issue.
This time the tool fails, and your agent shares why.
Denial happened at the token exchange request evaluation, before any downstream Linear call. Policy forbids write, which is required for trashing issues. Since the policy denied the request, no credential was issued. There are no over-privileged tokens floating around that could be used or stolen.
Try requesting admin scopes to delete the issue. The policy still forbids any scope outside its allowlist, even if the MCP server doesn’t know about it.
View the denial in the audit log
Section titled “View the denial in the audit log”-
Open your Keycard Audit Log. The newest Linear
credentials:issueentry failed. -
Open it to see Status:
Failure. The Actor Details show the same two-identity chain (your application + you) as every successful exchange. -
Check the raw Request Information: the
attributesblock shows error information and messaging along with"scopes": ["write"]. The error message cites the policy, policy set, and version that denied the request. Belowattributes, the credential details show an emptycredential_type, no expiration, and noscopesbecause the Linear API credential was never issued.
Three layers of least privilege
Section titled “Three layers of least privilege”Here are the mechanisms you used to achieve least privilege for Linear:
- Linear’s own model is the platform layer. Your Linear OAuth app was granted only
read,write, andissues:createduring authorization in Chapter 3. - Your forbid unless policy is the governance layer. Anything not in your policy allowlist is rejected. Denial at the policy layer means no credential is issued for that request.
- Your code is the application layer. Policy operates at scope level; it can’t read ticket contents. Policy allows
issues:createwhether the content is masked or raw. This is why the PII rewrite in Chapter 5 lives in the server. Anything more granular than a scope is the code’s job.
No single layer covers the others’ blind spots, and none of them needs to. The platform bounds what’s possible, policy bounds what’s permitted, and code handles what’s left. Now you’ve built this workshop’s full least privilege story.