cedar-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.github/workflows/release.yml +42 -0
  4. package/.nvmrc +1 -0
  5. package/CHANGELOG.md +241 -0
  6. package/CONTRIBUTING.md +83 -0
  7. package/LICENSE +182 -0
  8. package/README.md +1635 -0
  9. package/SECURITY.md +37 -0
  10. package/dist/http-server.d.ts +61 -0
  11. package/dist/http-server.d.ts.map +1 -0
  12. package/dist/http-server.js +194 -0
  13. package/dist/http-server.js.map +1 -0
  14. package/dist/index.d.ts +32 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +270 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parser/policy-ast.d.ts +49 -0
  19. package/dist/parser/policy-ast.d.ts.map +1 -0
  20. package/dist/parser/policy-ast.js +311 -0
  21. package/dist/parser/policy-ast.js.map +1 -0
  22. package/dist/prompts/index.d.ts +38 -0
  23. package/dist/prompts/index.d.ts.map +1 -0
  24. package/dist/prompts/index.js +172 -0
  25. package/dist/prompts/index.js.map +1 -0
  26. package/dist/resources/ref-resolver.d.ts +23 -0
  27. package/dist/resources/ref-resolver.d.ts.map +1 -0
  28. package/dist/resources/ref-resolver.js +128 -0
  29. package/dist/resources/ref-resolver.js.map +1 -0
  30. package/dist/resources/store-manager.d.ts +64 -0
  31. package/dist/resources/store-manager.d.ts.map +1 -0
  32. package/dist/resources/store-manager.js +221 -0
  33. package/dist/resources/store-manager.js.map +1 -0
  34. package/dist/server.d.ts +18 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +539 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/tools/advise/avp-rules.d.ts +49 -0
  39. package/dist/tools/advise/avp-rules.d.ts.map +1 -0
  40. package/dist/tools/advise/avp-rules.js +59 -0
  41. package/dist/tools/advise/avp-rules.js.map +1 -0
  42. package/dist/tools/advise/cedar-patterns.d.ts +24 -0
  43. package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
  44. package/dist/tools/advise/cedar-patterns.js +57 -0
  45. package/dist/tools/advise/cedar-patterns.js.map +1 -0
  46. package/dist/tools/advise/context-builder.d.ts +28 -0
  47. package/dist/tools/advise/context-builder.d.ts.map +1 -0
  48. package/dist/tools/advise/context-builder.js +89 -0
  49. package/dist/tools/advise/context-builder.js.map +1 -0
  50. package/dist/tools/advise/gotchas.d.ts +15 -0
  51. package/dist/tools/advise/gotchas.d.ts.map +1 -0
  52. package/dist/tools/advise/gotchas.js +83 -0
  53. package/dist/tools/advise/gotchas.js.map +1 -0
  54. package/dist/tools/advise.d.ts +96 -0
  55. package/dist/tools/advise.d.ts.map +1 -0
  56. package/dist/tools/advise.js +258 -0
  57. package/dist/tools/advise.js.map +1 -0
  58. package/dist/tools/authorize-batch.d.ts +35 -0
  59. package/dist/tools/authorize-batch.d.ts.map +1 -0
  60. package/dist/tools/authorize-batch.js +262 -0
  61. package/dist/tools/authorize-batch.js.map +1 -0
  62. package/dist/tools/authorize.d.ts +115 -0
  63. package/dist/tools/authorize.d.ts.map +1 -0
  64. package/dist/tools/authorize.js +373 -0
  65. package/dist/tools/authorize.js.map +1 -0
  66. package/dist/tools/check-change.d.ts +19 -0
  67. package/dist/tools/check-change.d.ts.map +1 -0
  68. package/dist/tools/check-change.js +91 -0
  69. package/dist/tools/check-change.js.map +1 -0
  70. package/dist/tools/diff-schema.d.ts +103 -0
  71. package/dist/tools/diff-schema.d.ts.map +1 -0
  72. package/dist/tools/diff-schema.js +379 -0
  73. package/dist/tools/diff-schema.js.map +1 -0
  74. package/dist/tools/diff-stores.d.ts +45 -0
  75. package/dist/tools/diff-stores.d.ts.map +1 -0
  76. package/dist/tools/diff-stores.js +222 -0
  77. package/dist/tools/diff-stores.js.map +1 -0
  78. package/dist/tools/explain.d.ts +80 -0
  79. package/dist/tools/explain.d.ts.map +1 -0
  80. package/dist/tools/explain.js +187 -0
  81. package/dist/tools/explain.js.map +1 -0
  82. package/dist/tools/format.d.ts +11 -0
  83. package/dist/tools/format.d.ts.map +1 -0
  84. package/dist/tools/format.js +20 -0
  85. package/dist/tools/format.js.map +1 -0
  86. package/dist/tools/generate-sample.d.ts +28 -0
  87. package/dist/tools/generate-sample.d.ts.map +1 -0
  88. package/dist/tools/generate-sample.js +568 -0
  89. package/dist/tools/generate-sample.js.map +1 -0
  90. package/dist/tools/link-template.d.ts +17 -0
  91. package/dist/tools/link-template.d.ts.map +1 -0
  92. package/dist/tools/link-template.js +78 -0
  93. package/dist/tools/link-template.js.map +1 -0
  94. package/dist/tools/list-template-links.d.ts +16 -0
  95. package/dist/tools/list-template-links.d.ts.map +1 -0
  96. package/dist/tools/list-template-links.js +22 -0
  97. package/dist/tools/list-template-links.js.map +1 -0
  98. package/dist/tools/list-templates.d.ts +16 -0
  99. package/dist/tools/list-templates.d.ts.map +1 -0
  100. package/dist/tools/list-templates.js +36 -0
  101. package/dist/tools/list-templates.js.map +1 -0
  102. package/dist/tools/translate.d.ts +11 -0
  103. package/dist/tools/translate.d.ts.map +1 -0
  104. package/dist/tools/translate.js +53 -0
  105. package/dist/tools/translate.js.map +1 -0
  106. package/dist/tools/validate-entities.d.ts +19 -0
  107. package/dist/tools/validate-entities.d.ts.map +1 -0
  108. package/dist/tools/validate-entities.js +88 -0
  109. package/dist/tools/validate-entities.js.map +1 -0
  110. package/dist/tools/validate-schema.d.ts +22 -0
  111. package/dist/tools/validate-schema.d.ts.map +1 -0
  112. package/dist/tools/validate-schema.js +89 -0
  113. package/dist/tools/validate-schema.js.map +1 -0
  114. package/dist/tools/validate-template.d.ts +18 -0
  115. package/dist/tools/validate-template.d.ts.map +1 -0
  116. package/dist/tools/validate-template.js +59 -0
  117. package/dist/tools/validate-template.js.map +1 -0
  118. package/dist/tools/validate.d.ts +90 -0
  119. package/dist/tools/validate.d.ts.map +1 -0
  120. package/dist/tools/validate.js +351 -0
  121. package/dist/tools/validate.js.map +1 -0
  122. package/dist/utils/format-detector.d.ts +49 -0
  123. package/dist/utils/format-detector.d.ts.map +1 -0
  124. package/dist/utils/format-detector.js +298 -0
  125. package/dist/utils/format-detector.js.map +1 -0
  126. package/examples/README.md +36 -0
  127. package/examples/abac-multi-tenant/README.md +150 -0
  128. package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
  129. package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
  130. package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
  131. package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
  132. package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
  133. package/examples/abac-multi-tenant/run.ts +92 -0
  134. package/examples/abac-multi-tenant/schema.json +60 -0
  135. package/examples/api-gateway-path-routing/README.md +154 -0
  136. package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
  137. package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
  138. package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
  139. package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
  140. package/examples/api-gateway-path-routing/run.ts +108 -0
  141. package/examples/api-gateway-path-routing/schema.json +54 -0
  142. package/examples/rbac-document-management/README.md +167 -0
  143. package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
  144. package/examples/rbac-document-management/policies/admin.cedar +6 -0
  145. package/examples/rbac-document-management/policies/editor.cedar +6 -0
  146. package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
  147. package/examples/rbac-document-management/policies/viewer.cedar +6 -0
  148. package/examples/rbac-document-management/run.ts +87 -0
  149. package/examples/rbac-document-management/schema.json +57 -0
  150. package/package.json +50 -0
  151. package/src/http-server.ts +239 -0
  152. package/src/index.ts +294 -0
  153. package/src/parser/policy-ast.ts +345 -0
  154. package/src/prompts/README.md +3 -0
  155. package/src/prompts/index.ts +217 -0
  156. package/src/resources/ref-resolver.ts +134 -0
  157. package/src/resources/store-manager.ts +248 -0
  158. package/src/server.ts +711 -0
  159. package/src/tools/advise/avp-rules.ts +70 -0
  160. package/src/tools/advise/cedar-patterns.ts +73 -0
  161. package/src/tools/advise/context-builder.ts +109 -0
  162. package/src/tools/advise/gotchas.ts +92 -0
  163. package/src/tools/advise.ts +366 -0
  164. package/src/tools/authorize-batch.ts +345 -0
  165. package/src/tools/authorize.ts +464 -0
  166. package/src/tools/check-change.ts +119 -0
  167. package/src/tools/diff-schema.ts +510 -0
  168. package/src/tools/diff-stores.ts +298 -0
  169. package/src/tools/explain.ts +278 -0
  170. package/src/tools/format.ts +33 -0
  171. package/src/tools/generate-sample.ts +665 -0
  172. package/src/tools/link-template.ts +109 -0
  173. package/src/tools/list-template-links.ts +41 -0
  174. package/src/tools/list-templates.ts +55 -0
  175. package/src/tools/translate.ts +66 -0
  176. package/src/tools/validate-entities.ts +125 -0
  177. package/src/tools/validate-schema.ts +128 -0
  178. package/src/tools/validate-template.ts +72 -0
  179. package/src/tools/validate.ts +459 -0
  180. package/src/utils/format-detector.ts +356 -0
  181. package/test/fixtures/docmgmt.ts +121 -0
  182. package/test/fixtures/multitenant.ts +163 -0
  183. package/test/index.test.ts +96 -0
  184. package/test/integration/e2e/behavior.test.ts +359 -0
  185. package/test/integration/e2e/edge-cases.test.ts +365 -0
  186. package/test/integration/e2e/failure-modes.test.ts +266 -0
  187. package/test/integration/e2e/protocol.test.ts +252 -0
  188. package/test/integration/http-smoke.test.ts +588 -0
  189. package/test/integration/smoke.test.ts +475 -0
  190. package/test/prompts/prompts.test.ts +173 -0
  191. package/test/property/properties.test.ts +234 -0
  192. package/test/resources/ref-resolver.test.ts +186 -0
  193. package/test/resources/store-manager.test.ts +344 -0
  194. package/test/setup.test.ts +7 -0
  195. package/test/tools/advise/avp-rules.test.ts +76 -0
  196. package/test/tools/advise.test.ts +339 -0
  197. package/test/tools/authorize-batch.test.ts +459 -0
  198. package/test/tools/authorize.test.ts +682 -0
  199. package/test/tools/check-change.test.ts +104 -0
  200. package/test/tools/cross-fixture.test.ts +170 -0
  201. package/test/tools/diff-schema.test.ts +355 -0
  202. package/test/tools/diff-stores.test.ts +291 -0
  203. package/test/tools/explain.test.ts +221 -0
  204. package/test/tools/format.test.ts +33 -0
  205. package/test/tools/generate-sample.test.ts +480 -0
  206. package/test/tools/link-template.test.ts +90 -0
  207. package/test/tools/list-templates.test.ts +151 -0
  208. package/test/tools/translate.test.ts +89 -0
  209. package/test/tools/validate-entities.test.ts +178 -0
  210. package/test/tools/validate-schema.test.ts +86 -0
  211. package/test/tools/validate-template.test.ts +89 -0
  212. package/test/tools/validate.test.ts +331 -0
  213. package/test/utils/format-detector.test.ts +518 -0
  214. package/tsconfig.json +17 -0
  215. package/vitest.config.ts +13 -0
@@ -0,0 +1,480 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { handleGenerateSample } from "../../src/tools/generate-sample.js";
3
+ import { SCHEMA_JSON } from "../fixtures/docmgmt.js";
4
+
5
+ const DOCMGMT_SCHEMA_STR = JSON.stringify(SCHEMA_JSON);
6
+
7
+ // Generic ABAC schema for cases 5.2-5.5
8
+ const ABAC_SCHEMA = JSON.stringify({
9
+ MyApp: {
10
+ entityTypes: {
11
+ User: {
12
+ memberOfTypes: [],
13
+ shape: {
14
+ type: "Record",
15
+ attributes: {
16
+ name: { type: "String", required: true },
17
+ },
18
+ },
19
+ },
20
+ Resource: {
21
+ memberOfTypes: [],
22
+ shape: {
23
+ type: "Record",
24
+ attributes: {
25
+ type: { type: "String", required: true },
26
+ region: { type: "String", required: true },
27
+ tag: { type: "String", required: false },
28
+ status: { type: "String", required: false },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ actions: {
34
+ READ: {
35
+ appliesTo: {
36
+ principalTypes: ["User"],
37
+ resourceTypes: ["Resource"],
38
+ context: { type: "Record", attributes: {} },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ });
44
+
45
+ describe("cedar_generate_sample_request", () => {
46
+ it("5.1 — simple RBAC: generates allow request for admin role", async () => {
47
+ const result = await handleGenerateSample({
48
+ policy: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
49
+ schema: DOCMGMT_SCHEMA_STR,
50
+ target_decision: "allow",
51
+ });
52
+
53
+ expect(result.error).toBeUndefined();
54
+ expect(result.decision).toBe("Allow");
55
+ expect(result.ready_to_test).toBe(true);
56
+ expect(result.entities.some((e: { uid: { type: string } }) => e.uid.type === "DocMgmt::Role")).toBe(true);
57
+ });
58
+
59
+ it("5.2 — ABAC: generates allow request satisfying all conditions", async () => {
60
+ const result = await handleGenerateSample({
61
+ policy: `permit(
62
+ principal,
63
+ action in [MyApp::Action::"READ"],
64
+ resource
65
+ ) when {
66
+ principal.name == "service_x" &&
67
+ resource.type == "report" &&
68
+ resource.region == "us-east"
69
+ };`,
70
+ schema: ABAC_SCHEMA,
71
+ target_decision: "allow",
72
+ });
73
+
74
+ expect(result.error).toBeUndefined();
75
+ expect(result.decision).toBe("Allow");
76
+ expect(result.ready_to_test).toBe(true);
77
+ // Principal should have name = "service_x"
78
+ const principal = result.entities.find((e: { uid: { id: string } }) => e.uid.id === result.principal.split("::")?.[2]?.replace(/"/g, "") || e.uid.type?.includes("Identity"));
79
+ expect(principal).toBeDefined();
80
+ });
81
+
82
+ it("5.3 — ABAC: generates deny request violating exactly one condition", async () => {
83
+ const result = await handleGenerateSample({
84
+ policy: `permit(
85
+ principal,
86
+ action in [MyApp::Action::"READ"],
87
+ resource
88
+ ) when {
89
+ principal.name == "service_x" &&
90
+ resource.type == "report" &&
91
+ resource.region == "us-east"
92
+ };`,
93
+ schema: ABAC_SCHEMA,
94
+ target_decision: "deny",
95
+ });
96
+
97
+ expect(result.error).toBeUndefined();
98
+ expect(result.decision).toBe("Deny");
99
+ expect(result.ready_to_test).toBe(true);
100
+ });
101
+
102
+ it("5.4 — optional attribute guard: allow request includes the optional attribute", async () => {
103
+ const result = await handleGenerateSample({
104
+ policy: `permit(
105
+ principal,
106
+ action in [MyApp::Action::"READ"],
107
+ resource
108
+ ) when {
109
+ principal.name == "service_x" &&
110
+ resource has tag &&
111
+ resource.tag == "confidential"
112
+ };`,
113
+ schema: ABAC_SCHEMA,
114
+ target_decision: "allow",
115
+ });
116
+
117
+ expect(result.error).toBeUndefined();
118
+ expect(result.decision).toBe("Allow");
119
+ const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
120
+ expect(resource?.attrs?.tag).toBe("confidential");
121
+ });
122
+
123
+ it("5.5 — optional attribute guard: deny request omits the guarded attribute", async () => {
124
+ const result = await handleGenerateSample({
125
+ policy: `permit(
126
+ principal,
127
+ action in [MyApp::Action::"READ"],
128
+ resource
129
+ ) when {
130
+ principal.name == "service_x" &&
131
+ resource has tag &&
132
+ resource.tag == "confidential"
133
+ };`,
134
+ schema: ABAC_SCHEMA,
135
+ target_decision: "deny",
136
+ });
137
+
138
+ expect(result.error).toBeUndefined();
139
+ expect(result.decision).toBe("Deny");
140
+ const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
141
+ // The resource should NOT have category (omitting the optional attr is the deny strategy)
142
+ expect(resource?.attrs?.tag).toBeUndefined();
143
+ });
144
+
145
+ // Fix 2: required schema attributes are populated on generated entities
146
+ it("populates required schema attributes even when not mentioned in policy conditions", async () => {
147
+ // The DocMgmt schema requires name+email on User and owner+classification on Document
148
+ // The policy only checks role membership — no condition references these attrs
149
+ // Without the fix, generated entities miss required attrs and validateRequest: true fails
150
+ const result = await handleGenerateSample({
151
+ policy: `permit(principal in DocMgmt::Role::"admin", action, resource);`,
152
+ schema: DOCMGMT_SCHEMA_STR,
153
+ target_decision: "allow",
154
+ });
155
+
156
+ expect(result.error).toBeUndefined();
157
+ expect(result.decision).toBe("Allow");
158
+
159
+ const principal = result.entities.find((e: { uid: { type: string } }) =>
160
+ e.uid.type?.includes("User")
161
+ );
162
+ const resource = result.entities.find((e: { uid: { type: string } }) =>
163
+ e.uid.type?.includes("Document")
164
+ );
165
+
166
+ // Required attrs from schema: User has name (String) and email (String)
167
+ expect(principal?.attrs).toHaveProperty("name");
168
+ expect(principal?.attrs).toHaveProperty("email");
169
+ // Required attrs from schema: Document has owner (String) and classification (String)
170
+ expect(resource?.attrs).toHaveProperty("owner");
171
+ expect(resource?.attrs).toHaveProperty("classification");
172
+ });
173
+
174
+ // Fix 4: entity types read from schema instead of hardcoded User/Resource
175
+ it("uses schema entity types (Endpoint not Resource) when defined in appliesTo", async () => {
176
+ const result = await handleGenerateSample({
177
+ policy: `permit(principal in Gateway::Role::"readonly", action in [Gateway::Action::"GET"], resource);`,
178
+ schema: GATEWAY_SCHEMA,
179
+ target_decision: "allow",
180
+ });
181
+
182
+ expect(result.error).toBeUndefined();
183
+ expect(result.decision).toBe("Allow");
184
+ // Principal should be Gateway::User (from appliesTo.principalTypes), not Gateway::User (same here)
185
+ // Resource should be Gateway::Endpoint (from appliesTo.resourceTypes), not Gateway::Resource
186
+ expect(result.resource).toContain("Gateway::Endpoint");
187
+ expect(result.principal).toContain("Gateway::User");
188
+ });
189
+
190
+ // Fix 5: in/contains conditions extracted and satisfied
191
+ it("extracts contains() conditions and satisfies them for allow", async () => {
192
+ const result = await handleGenerateSample({
193
+ policy: `permit(principal, action in [MyApp::Action::"READ"], resource) when { ["active", "pending"].contains(resource.status) };`,
194
+ schema: ABAC_SCHEMA,
195
+ target_decision: "allow",
196
+ });
197
+
198
+ expect(result.error).toBeUndefined();
199
+ expect(result.decision).toBe("Allow");
200
+ const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
201
+ expect(["active", "pending"]).toContain(resource?.attrs?.status);
202
+ });
203
+
204
+ it("extracts contains() conditions and violates them for deny", async () => {
205
+ const result = await handleGenerateSample({
206
+ policy: `permit(principal, action in [MyApp::Action::"READ"], resource) when { ["active", "pending"].contains(resource.status) };`,
207
+ schema: ABAC_SCHEMA,
208
+ target_decision: "deny",
209
+ });
210
+
211
+ expect(result.error).toBeUndefined();
212
+ expect(result.decision).toBe("Deny");
213
+ const resource = result.entities.find((e: { uid: { type: string } }) => e.uid.type?.includes("Resource"));
214
+ expect(["active", "pending"]).not.toContain(resource?.attrs?.status);
215
+ });
216
+
217
+ // Path-matching cases (require like operator support)
218
+ // Schema: Gateway namespace with Endpoint entity having a path attribute
219
+
220
+ const GATEWAY_SCHEMA = JSON.stringify({
221
+ Gateway: {
222
+ entityTypes: {
223
+ User: {
224
+ memberOfTypes: ["Role"],
225
+ shape: { type: "Record", attributes: {} },
226
+ },
227
+ Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
228
+ Endpoint: {
229
+ memberOfTypes: [],
230
+ shape: {
231
+ type: "Record",
232
+ attributes: {
233
+ path: { type: "String", required: true },
234
+ },
235
+ },
236
+ },
237
+ },
238
+ actions: {
239
+ GET: {
240
+ appliesTo: {
241
+ principalTypes: ["User"],
242
+ resourceTypes: ["Endpoint"],
243
+ context: { type: "Record", attributes: {} },
244
+ },
245
+ },
246
+ },
247
+ },
248
+ });
249
+
250
+ const PATH_POLICY = `permit (
251
+ principal in Gateway::Role::"readonly",
252
+ action in [Gateway::Action::"GET"],
253
+ resource
254
+ )
255
+ when {
256
+ resource.path == "/api/v1/policies"
257
+ || (
258
+ resource.path like "/api/v1/policies/*"
259
+ && !(resource.path like "/api/v1/policies/*/*")
260
+ )
261
+ };`;
262
+
263
+ it("5.6 — path-matching allow: generated path satisfies the policy", async () => {
264
+ const result = await handleGenerateSample({
265
+ policy: PATH_POLICY,
266
+ schema: GATEWAY_SCHEMA,
267
+ target_decision: "allow",
268
+ });
269
+
270
+ expect(result.error).toBeUndefined();
271
+ expect(result.decision).toBe("Allow");
272
+ expect(result.ready_to_test).toBe(true);
273
+ });
274
+
275
+ it("5.7 — path-matching deny: generated path violates depth limit", async () => {
276
+ const result = await handleGenerateSample({
277
+ policy: PATH_POLICY,
278
+ schema: GATEWAY_SCHEMA,
279
+ target_decision: "deny",
280
+ });
281
+
282
+ expect(result.error).toBeUndefined();
283
+ expect(result.decision).toBe("Deny");
284
+ expect(result.ready_to_test).toBe(true);
285
+ });
286
+
287
+ it("picks an action whose appliesTo matches the scope's principal type, not just the first declared action", async () => {
288
+ // Regression for the v1 → v2 fix of defaultActionIdFromSchema. v1 returned
289
+ // Object.keys(actions)[0], which broke when the first action's
290
+ // appliesTo.principalTypes didn't include the policy's principal type.
291
+ //
292
+ // Failure case: schema with `adminOnly` declared FIRST (admins only) and
293
+ // `userRead` declared second (users only). A policy targeting a User would
294
+ // pick `adminOnly` under v1, schema validation rejects, generator outputs
295
+ // ready_to_test:false. Under v2 the generator picks `userRead`.
296
+ const schemaWithOrder = JSON.stringify({
297
+ Mismatch: {
298
+ entityTypes: {
299
+ User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
300
+ Admin: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
301
+ Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
302
+ },
303
+ actions: {
304
+ adminOnly: { appliesTo: { principalTypes: ["Admin"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } },
305
+ userRead: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } },
306
+ },
307
+ },
308
+ });
309
+ // Policy with NO action restriction — generator must default-pick an action.
310
+ // The principal is a User (per generator's principalType picked from the
311
+ // userRead action), so v2 should select `userRead`, not `adminOnly`.
312
+ const policy = `permit (principal, action, resource);`;
313
+ // Wait — extractScope picks principalType from the FIRST action's appliesTo
314
+ // when actionId is undefined (see entityTypesFromSchema fallback). That
315
+ // returns "Admin" (first action's principal type). So the generator would
316
+ // build a request as Admin + adminOnly. Both pieces agree but the v2 fix
317
+ // doesn't yet help because the principal type is also derived from the
318
+ // first action.
319
+ //
320
+ // To exercise the v2 fix specifically, use a policy that PINS the principal
321
+ // type (via `principal == User::"x"`) but leaves action unrestricted.
322
+ const pinnedPolicy = `permit (principal == Mismatch::User::"alice", action, resource);`;
323
+
324
+ const result = await handleGenerateSample({
325
+ policy: pinnedPolicy,
326
+ schema: schemaWithOrder,
327
+ target_decision: "allow",
328
+ });
329
+
330
+ expect(result.error).toBeUndefined();
331
+ // The generated action must match the User principal. adminOnly does NOT
332
+ // include User in its appliesTo; userRead does. v2 must pick userRead.
333
+ expect(result.action).toBe('Mismatch::Action::"userRead"');
334
+ void policy; // kept above as a written-out exploration; not used
335
+ });
336
+
337
+ // ─── kickoff-14 14b: double-namespace fix ──────────────────────────────────
338
+
339
+ it("kickoff-14 14b: cedarschema-text schema produces single-prefix principal/resource (no MyApp::MyApp::User)", async () => {
340
+ // The cwd-fallback path for cedar-sandbox supplies a .cedarschema text. The
341
+ // Cedar WASM `schemaToJsonWithResolvedTypes` emits already-namespaced type
342
+ // strings ("MyApp::User") for entries declared inside `namespace MyApp { ... }`.
343
+ // The generator used to wrap that in `${namespace}::${type}` again,
344
+ // producing `MyApp::MyApp::User::"sample-principal"`. Fix: skip re-prefixing
345
+ // when the type name already contains "::".
346
+ const cedarSchema = `namespace MyApp {
347
+ entity User { name: String };
348
+ entity Document { owner: String };
349
+ action "read" appliesTo { principal: User, resource: Document };
350
+ }`;
351
+ const result = await handleGenerateSample({
352
+ policy: `permit (principal, action, resource);`,
353
+ schema: cedarSchema,
354
+ target_decision: "allow",
355
+ });
356
+
357
+ expect(result.error).toBeUndefined();
358
+ expect(result.principal).toBe('MyApp::User::"sample-principal"');
359
+ expect(result.resource).toBe('MyApp::Document::"sample-resource"');
360
+ expect(result.action).toBe('MyApp::Action::"read"');
361
+ // Entity uids must use the same single-namespace form (not "MyApp::MyApp::User").
362
+ expect(result.entities.some((e) => e.uid.type === "MyApp::User" && e.uid.id === "sample-principal")).toBe(true);
363
+ expect(result.entities.some((e) => e.uid.type === "MyApp::Document" && e.uid.id === "sample-resource")).toBe(true);
364
+ expect(result.entities.every((e) => !e.uid.type.startsWith("MyApp::MyApp::"))).toBe(true);
365
+ });
366
+
367
+ it("kickoff-14 14b: a different namespace name (OtherApp) also gets single-prefix output", async () => {
368
+ const cedarSchema = `namespace OtherApp {
369
+ entity User { name: String };
370
+ entity Document;
371
+ action "read" appliesTo { principal: User, resource: Document };
372
+ }`;
373
+ const result = await handleGenerateSample({
374
+ policy: `permit (principal, action, resource);`,
375
+ schema: cedarSchema,
376
+ target_decision: "allow",
377
+ });
378
+
379
+ expect(result.error).toBeUndefined();
380
+ expect(result.principal).toBe('OtherApp::User::"sample-principal"');
381
+ expect(result.resource).toBe('OtherApp::Document::"sample-resource"');
382
+ expect(result.entities.every((e) => !e.uid.type.startsWith("OtherApp::OtherApp::"))).toBe(true);
383
+ });
384
+
385
+ it("kickoff-14 14d F2: namespaceless JSON schema (empty-string namespace key) generates bare entity refs", async () => {
386
+ // Cedar's "no namespace" form is an empty-string key:
387
+ // `{"": {entityTypes: {...}}}`. The original `if (ns)` guard treated ""
388
+ // as falsy and silently fell back to `schemaNamespace = "MyApp"`, leaking
389
+ // a hallucinated namespace into the generated principal / resource /
390
+ // action references. The fix replaces the truthiness check with
391
+ // `ns !== undefined` so Cedar's legitimate empty namespace stays empty.
392
+ const namespacelessSchema = JSON.stringify({
393
+ "": {
394
+ entityTypes: {
395
+ User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
396
+ Document: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
397
+ },
398
+ actions: {
399
+ read: {
400
+ appliesTo: {
401
+ principalTypes: ["User"],
402
+ resourceTypes: ["Document"],
403
+ context: { type: "Record", attributes: {} },
404
+ },
405
+ },
406
+ },
407
+ },
408
+ });
409
+ const result = await handleGenerateSample({
410
+ policy: `permit (principal, action, resource);`,
411
+ schema: namespacelessSchema,
412
+ target_decision: "allow",
413
+ });
414
+
415
+ expect(result.error).toBeUndefined();
416
+ // Bare refs — no namespace prefix at all (not "MyApp::..." and not "::User").
417
+ expect(result.principal).toBe('User::"sample-principal"');
418
+ expect(result.resource).toBe('Document::"sample-resource"');
419
+ expect(result.action).toBe('Action::"read"');
420
+ expect(result.entities.every((e) => !e.uid.type.includes("::"))).toBe(true);
421
+ expect(result.ready_to_test).toBe(true);
422
+ });
423
+
424
+ it("kickoff-14 14d F3: ready_to_test is false (and explanation flags the mismatch) when the generator's output doesn't satisfy the user's schema", async () => {
425
+ // F3 surfaced that the internal isAuthorized verification call did not
426
+ // include the user's schema, so a payload referencing entity types the
427
+ // schema doesn't declare still got `ready_to_test: true`. Fix: pass schema
428
+ // with validateRequest:true. This test pins that the verification now
429
+ // reflects schema reality by feeding a policy that pins a principal type
430
+ // the schema does not declare (`Ghost::User`).
431
+ const schema = JSON.stringify({
432
+ Real: {
433
+ entityTypes: {
434
+ User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
435
+ Document: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
436
+ },
437
+ actions: {
438
+ read: {
439
+ appliesTo: { principalTypes: ["User"], resourceTypes: ["Document"], context: { type: "Record", attributes: {} } },
440
+ },
441
+ },
442
+ },
443
+ });
444
+ // The policy pins a principal type from a namespace the schema doesn't declare.
445
+ const policy = `permit (principal == Ghost::User::"alice", action, resource);`;
446
+ const result = await handleGenerateSample({
447
+ policy,
448
+ schema,
449
+ target_decision: "allow",
450
+ });
451
+
452
+ // The tool returns gracefully (no thrown exception) but signals the mismatch.
453
+ // Either via `error` (schema-validation failure path) or via
454
+ // `ready_to_test: false` with explanation. The contract: the response must
455
+ // NOT claim ready_to_test:true on a payload Cedar would reject under the
456
+ // user's schema.
457
+ if (result.ready_to_test === true) {
458
+ throw new Error(`ready_to_test was incorrectly true. Result: ${JSON.stringify(result, null, 2)}`);
459
+ }
460
+ // The verification path catches the schema-mismatched principal type.
461
+ expect(result.error ?? result.explanation).toMatch(/schema|principal|entity/i);
462
+ });
463
+
464
+ it("kickoff-14 14b: JSON-format schema (already bare types) keeps single namespace", async () => {
465
+ // Regression: the JSON schema path supplies bare entity-type names ("User",
466
+ // "Document"), so qualifyEntityType prefixes with the namespace. Existing
467
+ // tests already exercise this path; this assertion just pins that the fix
468
+ // didn't accidentally break it.
469
+ const result = await handleGenerateSample({
470
+ policy: `permit (principal, action, resource);`,
471
+ schema: ABAC_SCHEMA,
472
+ target_decision: "allow",
473
+ });
474
+
475
+ expect(result.error).toBeUndefined();
476
+ expect(result.principal).toBe('MyApp::User::"sample-principal"');
477
+ expect(result.resource).toBe('MyApp::Resource::"sample-resource"');
478
+ expect(result.entities.every((e) => !e.uid.type.startsWith("MyApp::MyApp::"))).toBe(true);
479
+ });
480
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { handleLinkTemplate } from "../../src/tools/link-template.js";
3
+
4
+ const SCHEMA = `namespace App {
5
+ entity User;
6
+ entity Document;
7
+ action read appliesTo { principal: [User], resource: [Document], context: {} };
8
+ }`;
9
+
10
+ const BOTH_SLOTS_TEMPLATE = `permit(
11
+ principal == ?principal,
12
+ action == App::Action::"read",
13
+ resource == ?resource
14
+ );`;
15
+
16
+ const RESOURCE_ONLY_TEMPLATE = `permit(
17
+ principal,
18
+ action == App::Action::"read",
19
+ resource == ?resource
20
+ );`;
21
+
22
+ describe("cedar_link_template", () => {
23
+ it("LT1 — links both slots to produce a valid Cedar policy", async () => {
24
+ const result = await handleLinkTemplate({
25
+ template: BOTH_SLOTS_TEMPLATE,
26
+ principal: 'App::User::"alice"',
27
+ resource: 'App::Document::"doc-42"',
28
+ });
29
+
30
+ expect(result.error).toBeUndefined();
31
+ expect(result.linked_policy).toContain('App::User::"alice"');
32
+ expect(result.linked_policy).toContain('App::Document::"doc-42"');
33
+ expect(result.slots_bound).toHaveProperty("?principal");
34
+ expect(result.slots_bound).toHaveProperty("?resource");
35
+ });
36
+
37
+ it("LT2 — linked policy validates against schema when schema provided", async () => {
38
+ const result = await handleLinkTemplate({
39
+ template: BOTH_SLOTS_TEMPLATE,
40
+ principal: 'App::User::"alice"',
41
+ resource: 'App::Document::"doc-42"',
42
+ schema: SCHEMA,
43
+ });
44
+
45
+ expect(result.valid).toBe(true);
46
+ expect(result.errors).toHaveLength(0);
47
+ });
48
+
49
+ it("LT3 — missing required slot returns error", async () => {
50
+ const result = await handleLinkTemplate({
51
+ template: BOTH_SLOTS_TEMPLATE,
52
+ // ?principal provided but ?resource missing
53
+ principal: 'App::User::"alice"',
54
+ });
55
+
56
+ expect(result.error).toBeDefined();
57
+ expect(result.error).toMatch(/\?resource/);
58
+ });
59
+
60
+ it("LT4 — resource-only template links with only resource slot", async () => {
61
+ const result = await handleLinkTemplate({
62
+ template: RESOURCE_ONLY_TEMPLATE,
63
+ resource: 'App::Document::"doc-99"',
64
+ });
65
+
66
+ expect(result.error).toBeUndefined();
67
+ expect(result.linked_policy).toContain('App::Document::"doc-99"');
68
+ expect(result.slots_bound).not.toHaveProperty("?principal");
69
+ });
70
+
71
+ it("LT5 — invalid template text returns error", async () => {
72
+ const result = await handleLinkTemplate({
73
+ template: "not valid cedar",
74
+ principal: 'App::User::"alice"',
75
+ resource: 'App::Document::"doc-1"',
76
+ });
77
+
78
+ expect(result.error).toBeDefined();
79
+ });
80
+
81
+ it("LT6 — invalid entity ref format returns error", async () => {
82
+ const result = await handleLinkTemplate({
83
+ template: BOTH_SLOTS_TEMPLATE,
84
+ principal: "not-an-entity-ref",
85
+ resource: 'App::Document::"doc-1"',
86
+ });
87
+
88
+ expect(result.error).toBeDefined();
89
+ });
90
+ });