Skip to content

Commit f1a30b2

Browse files
khaliqgantclaude
andauthored
feat(contract): Relayfile Path Contract v1 — grammar, fixtures, Go conformance runners (#357)
* feat(contract): codify path contract v1 — grammar doc, fixtures, Go conformance runners Phase 1 of the path-contract stabilization plan (cloud/specs/relayfile-path-contract-stabilization.md): document and fence the scope-path grammar exactly as internal/httpapi/auth.go enforces it — suffix globs only, mid-path segment globs invalid. - contract/README.md: authoritative v1 grammar (canonical paths, normalization, scope grammar, glob matching, containment, mounts_at), flagged in-repo divergences, and the packages/local-mount/src/mount.ts path-theory audit (Appendix A). - contract/fixtures/*.json: language-neutral conformance fixtures. Invalid scope paths (incl. /github/repos/*/*/issues/**, the live hosted/OSS divergence) assert valid:false AND carry probe_files that must not match — an implementation that accepts a mid-path glob fails the suite even without a validity check. - internal/httpapi/path_contract.go: reference scopePathValid / scopePathMountRoot (grammar codification; no runtime verdict changes). - Fixture runners in internal/httpapi and internal/relayfile against the real scopeMatchesPath / scopePathMatches / normalizePath / withinBase. - make contract-test: named entry point (also runs under go test ./..., so existing ci.yml/contract.yml already gate it). Prior art: cloud#989 (HTTP-level contract suite; this is the path-vocabulary layer beneath it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(contract): pin the / vs * asymmetry; document stricter-subset rule Review finding on #357: "/" was declared valid with mounts_at "/" but no fixture pinned that it authorizes only the root node itself — the same valid-but-authorizes-nothing failure class the contract exists to eliminate. Adds root-scope-path-* auth-matching cases (exact-node match only; everything beneath root denied), a §5.1 note on the asymmetry, and a "stricter subsets" section: producers may accept a documented subset of the grammar, enforcers must implement all of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5f1d6c6 commit f1a30b2

9 files changed

Lines changed: 1086 additions & 1 deletion

File tree

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ MOUNT_BIN := relayfile-mount
1919
TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64
2020
RELEASE_BINS := $(CLI_BIN) $(SERVER_BIN) $(MOUNT_BIN)
2121

22-
.PHONY: build build-all install test release clean
22+
.PHONY: build build-all install test contract-test release clean
2323

2424
build:
2525
mkdir -p $(BIN_DIR)
@@ -50,6 +50,12 @@ install: build
5050
test:
5151
$(GO) test ./...
5252

53+
# Path-contract conformance suite (contract/README.md + contract/fixtures/).
54+
# Also runs as part of plain `go test ./...`; this target is the named entry
55+
# point other repos' docs reference.
56+
contract-test:
57+
$(GO) test ./internal/httpapi ./internal/relayfile -run 'TestContractFixtures'
58+
5359
release: clean build-all
5460
set -eu; \
5561
for target in $(TARGETS); do \

contract/README.md

Lines changed: 344 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
{
2+
"contract": "relayfile-path-contract",
3+
"version": 1,
4+
"area": "auth-matching",
5+
"cases": [
6+
{
7+
"name": "exact-path-grant-matches-exact-file",
8+
"scopes": ["relayfile:fs:read:/src/app.ts"],
9+
"required": "fs:read",
10+
"file": "/src/app.ts",
11+
"expect": { "auth_matches": true }
12+
},
13+
{
14+
"name": "root-scope-path-matches-only-root-node",
15+
"description": "\"/\" is an exact path like any other: it matches the root node and nothing else. It is NOT a whole-tree grant — compare star-scope-path and /** cases.",
16+
"scopes": ["relayfile:fs:read:/"],
17+
"required": "fs:read",
18+
"file": "/",
19+
"expect": { "auth_matches": true }
20+
},
21+
{
22+
"name": "root-scope-path-denies-everything-beneath-root",
23+
"description": "Pins the / vs * asymmetry: both are valid with mounts_at \"/\", but / authorizes nothing beneath the root node. An author wanting the whole tree must write * or /**.",
24+
"scopes": ["relayfile:fs:read:/"],
25+
"required": "fs:read",
26+
"file": "/a.json",
27+
"expect": { "auth_matches": false }
28+
},
29+
{
30+
"name": "exact-path-grant-denies-other-file",
31+
"scopes": ["relayfile:fs:read:/src/app.ts"],
32+
"required": "fs:read",
33+
"file": "/.env",
34+
"expect": { "auth_matches": false }
35+
},
36+
{
37+
"name": "recursive-glob-matches-directory-node-itself",
38+
"scopes": ["relayfile:fs:read:/github/**"],
39+
"required": "fs:read",
40+
"file": "/github",
41+
"expect": { "auth_matches": true }
42+
},
43+
{
44+
"name": "recursive-glob-matches-deep-descendant",
45+
"scopes": ["relayfile:fs:read:/github/**"],
46+
"required": "fs:read",
47+
"file": "/github/repos/acme/cloud/issues/5.json",
48+
"expect": { "auth_matches": true }
49+
},
50+
{
51+
"name": "recursive-glob-denies-sibling-name-prefix",
52+
"scopes": ["relayfile:fs:read:/github/**"],
53+
"required": "fs:read",
54+
"file": "/github2/x",
55+
"expect": { "auth_matches": false }
56+
},
57+
{
58+
"name": "descendant-glob-denies-directory-node-itself",
59+
"description": "Unlike /**, /* excludes the directory node.",
60+
"scopes": ["relayfile:fs:read:/github/*"],
61+
"required": "fs:read",
62+
"file": "/github",
63+
"expect": { "auth_matches": false }
64+
},
65+
{
66+
"name": "descendant-glob-matches-any-depth",
67+
"description": "/* is an any-depth descendant match, NOT single-segment. Pins a known cross-impl disagreement candidate.",
68+
"scopes": ["relayfile:fs:read:/github/*"],
69+
"required": "fs:read",
70+
"file": "/github/repos/acme/cloud/issues/5.json",
71+
"expect": { "auth_matches": true }
72+
},
73+
{
74+
"name": "prefix-glob-matches-raw-byte-prefix",
75+
"scopes": ["relayfile:fs:read:/github/repos/acme-*"],
76+
"required": "fs:read",
77+
"file": "/github/repos/acme-cloud/issues/1.json",
78+
"expect": { "auth_matches": true }
79+
},
80+
{
81+
"name": "prefix-glob-denies-non-prefixed-sibling",
82+
"scopes": ["relayfile:fs:read:/github/repos/acme-*"],
83+
"required": "fs:read",
84+
"file": "/github/repos/other/issues/1.json",
85+
"expect": { "auth_matches": false }
86+
},
87+
{
88+
"name": "prefix-glob-crosses-segment-boundary",
89+
"description": "<name>* is a raw byte prefix: /github* matches /github itself and /github/x.",
90+
"scopes": ["relayfile:fs:read:/github*"],
91+
"required": "fs:read",
92+
"file": "/github/x",
93+
"expect": { "auth_matches": true }
94+
},
95+
{
96+
"name": "mid-path-segment-globs-authorize-nothing",
97+
"description": "THE production divergence (parent spec §2.1). Hosted TS currently allows this; the contract verdict is deny. An implementation returning true here fails the suite.",
98+
"scopes": ["relayfile:fs:read:/github/repos/*/*/issues/**"],
99+
"required": "fs:read",
100+
"file": "/github/repos/acme/cloud/issues/5.json",
101+
"expect": { "auth_matches": false }
102+
},
103+
{
104+
"name": "mid-path-single-star-authorizes-nothing",
105+
"scopes": ["relayfile:fs:read:/github/*/issues"],
106+
"required": "fs:read",
107+
"file": "/github/acme/issues",
108+
"expect": { "auth_matches": false }
109+
},
110+
{
111+
"name": "narrow-grant-suppresses-bare-literal-outside",
112+
"description": "Bare fs:read + a narrow path grant = confined to the narrow grant.",
113+
"scopes": ["fs:read", "relayfile:fs:read:/github/**"],
114+
"required": "fs:read",
115+
"file": "/slack/messages/thread-1.json",
116+
"expect": { "auth_matches": false }
117+
},
118+
{
119+
"name": "narrow-grant-with-bare-literal-allows-inside",
120+
"scopes": ["fs:read", "relayfile:fs:read:/github/**"],
121+
"required": "fs:read",
122+
"file": "/github/repos/acme/cloud/issues/5.json",
123+
"expect": { "auth_matches": true }
124+
},
125+
{
126+
"name": "pure-bare-literal-is-full-access",
127+
"scopes": ["fs:read"],
128+
"required": "fs:read",
129+
"file": "/anything/at/all.json",
130+
"expect": { "auth_matches": true }
131+
},
132+
{
133+
"name": "three-segment-grant-is-full-namespace",
134+
"scopes": ["relayfile:fs:read"],
135+
"required": "fs:read",
136+
"file": "/slack/users/user-1.json",
137+
"expect": { "auth_matches": true }
138+
},
139+
{
140+
"name": "star-scope-path-allows-empty-required-path",
141+
"scopes": ["relayfile:fs:read:*"],
142+
"required": "fs:read",
143+
"file": "",
144+
"expect": { "auth_matches": true }
145+
},
146+
{
147+
"name": "narrow-grant-denies-empty-required-path",
148+
"scopes": ["relayfile:fs:read:/github/**"],
149+
"required": "fs:read",
150+
"file": "",
151+
"expect": { "auth_matches": false }
152+
},
153+
{
154+
"name": "wildcard-action-matches-write",
155+
"scopes": ["relayfile:fs:*:/src/**"],
156+
"required": "fs:write",
157+
"file": "/src/app.ts",
158+
"expect": { "auth_matches": true }
159+
},
160+
{
161+
"name": "manage-implies-write",
162+
"scopes": ["relayfile:fs:manage:/src/**"],
163+
"required": "fs:write",
164+
"file": "/src/app.ts",
165+
"expect": { "auth_matches": true }
166+
},
167+
{
168+
"name": "manage-implies-read",
169+
"scopes": ["relayfile:fs:manage:/src/**"],
170+
"required": "fs:read",
171+
"file": "/src/app.ts",
172+
"expect": { "auth_matches": true }
173+
},
174+
{
175+
"name": "read-does-not-imply-write",
176+
"scopes": ["relayfile:fs:read:/src/**"],
177+
"required": "fs:write",
178+
"file": "/src/app.ts",
179+
"expect": { "auth_matches": false }
180+
},
181+
{
182+
"name": "read-does-not-imply-manage",
183+
"scopes": ["relayfile:fs:read:/src/**"],
184+
"required": "fs:manage",
185+
"file": "/src/app.ts",
186+
"expect": { "auth_matches": false }
187+
},
188+
{
189+
"name": "wildcard-resource-matches",
190+
"scopes": ["relayfile:*:read:/src/**"],
191+
"required": "fs:read",
192+
"file": "/src/app.ts",
193+
"expect": { "auth_matches": true }
194+
},
195+
{
196+
"name": "wildcard-plane-matches",
197+
"scopes": ["*:fs:read:/src/**"],
198+
"required": "fs:read",
199+
"file": "/src/app.ts",
200+
"expect": { "auth_matches": true }
201+
},
202+
{
203+
"name": "foreign-plane-never-matches",
204+
"scopes": ["relaycast:fs:read:/src/**"],
205+
"required": "fs:read",
206+
"file": "/src/app.ts",
207+
"expect": { "auth_matches": false }
208+
},
209+
{
210+
"name": "workspace-plane-second-segment-is-label-not-resource",
211+
"scopes": ["workspace:pear-integrations-slack-channels-c123-messages:read:/slack/channels/c123/messages/**"],
212+
"required": "fs:read",
213+
"file": "/slack/channels/c123/messages/1710000000.json",
214+
"expect": { "auth_matches": true }
215+
},
216+
{
217+
"name": "workspace-plane-grant-denies-outside-subtree",
218+
"scopes": ["workspace:mount-sponsor:read:/slack/messages/**"],
219+
"required": "fs:read",
220+
"file": "/slack/users/user-1.json",
221+
"expect": { "auth_matches": false }
222+
},
223+
{
224+
"name": "workspace-plane-three-segment-is-full-namespace",
225+
"scopes": ["workspace:mount-sponsor:read"],
226+
"required": "fs:read",
227+
"file": "/github/repos/acme/cloud/issues/5.json",
228+
"expect": { "auth_matches": true }
229+
},
230+
{
231+
"name": "workspace-plane-applies-to-fs-only",
232+
"scopes": ["workspace:mount-sponsor:read:/slack/**"],
233+
"required": "search:read",
234+
"file": "/slack/messages/thread-1.json",
235+
"expect": { "auth_matches": false }
236+
},
237+
{
238+
"name": "matching-is-case-sensitive",
239+
"scopes": ["relayfile:fs:read:/GitHub/**"],
240+
"required": "fs:read",
241+
"file": "/github/x",
242+
"expect": { "auth_matches": false }
243+
},
244+
{
245+
"name": "required-file-path-is-whitespace-trimmed",
246+
"scopes": ["relayfile:fs:read:/github/**"],
247+
"required": "fs:read",
248+
"file": " /github/x ",
249+
"expect": { "auth_matches": true }
250+
},
251+
{
252+
"name": "scope-path-is-whitespace-trimmed",
253+
"scopes": ["relayfile:fs:read: /github/** "],
254+
"required": "fs:read",
255+
"file": "/github/x",
256+
"expect": { "auth_matches": true }
257+
},
258+
{
259+
"name": "two-segment-scope-is-opaque-literal-not-grant",
260+
"description": "Scopes with <3 segments never act as path grants; they only match as exact literals of the requirement string.",
261+
"scopes": ["fs:write"],
262+
"required": "fs:read",
263+
"file": "/src/app.ts",
264+
"expect": { "auth_matches": false }
265+
},
266+
{
267+
"name": "empty-scope-set-denies",
268+
"scopes": [],
269+
"required": "fs:read",
270+
"file": "/src/app.ts",
271+
"expect": { "auth_matches": false }
272+
}
273+
]
274+
}

contract/fixtures/containment.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"contract": "relayfile-path-contract",
3+
"version": 1,
4+
"area": "containment",
5+
"cases": [
6+
{ "name": "root-contains-everything", "base": "/", "candidate": "/github/repos/acme/cloud/issues/5.json", "expect": { "contains": true } },
7+
{ "name": "root-contains-root", "base": "/", "candidate": "/", "expect": { "contains": true } },
8+
{ "name": "base-contains-itself", "base": "/github", "candidate": "/github", "expect": { "contains": true } },
9+
{ "name": "base-contains-deep-descendant", "base": "/github", "candidate": "/github/repos/acme/cloud", "expect": { "contains": true } },
10+
{ "name": "sibling-name-prefix-not-contained", "base": "/github", "candidate": "/github2", "expect": { "contains": false } },
11+
{ "name": "shorter-prefix-not-contained", "base": "/github", "candidate": "/gith", "expect": { "contains": false } },
12+
{ "name": "parent-not-contained-by-child", "base": "/a/b", "candidate": "/a", "expect": { "contains": false } },
13+
{ "name": "base-normalized-before-compare", "base": "github", "candidate": "/github/x", "expect": { "contains": true } },
14+
{ "name": "trailing-slash-base-normalized", "base": "/github/", "candidate": "/github/x", "expect": { "contains": true } },
15+
{ "name": "candidate-normalized-before-compare", "base": "/github", "candidate": "github/x/", "expect": { "contains": true } },
16+
{ "name": "case-sensitive", "base": "/github", "candidate": "/GitHub/x", "expect": { "contains": false } },
17+
{ "name": "unrelated-tree", "base": "/slack/messages", "candidate": "/github/repos/acme", "expect": { "contains": false } }
18+
]
19+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"contract": "relayfile-path-contract",
3+
"version": 1,
4+
"area": "path-normalization",
5+
"cases": [
6+
{ "name": "empty-becomes-root", "path": "", "expect": { "normalized": "/" } },
7+
{ "name": "root-unchanged", "path": "/", "expect": { "normalized": "/" } },
8+
{ "name": "missing-leading-slash-added", "path": "github", "expect": { "normalized": "/github" } },
9+
{ "name": "relative-multi-segment", "path": "a/b", "expect": { "normalized": "/a/b" } },
10+
{ "name": "canonical-unchanged", "path": "/github/repos/acme/cloud/issues/5.json", "expect": { "normalized": "/github/repos/acme/cloud/issues/5.json" } },
11+
{ "name": "single-trailing-slash-trimmed", "path": "/github/", "expect": { "normalized": "/github" } },
12+
{ "name": "relative-with-trailing-slash", "path": "a/b/", "expect": { "normalized": "/a/b" } },
13+
{
14+
"name": "double-trailing-slash-trims-one",
15+
"path": "/a//",
16+
"quirk": true,
17+
"description": "Only one trailing slash is trimmed. Pinned current behavior; candidate for a v2 tightening.",
18+
"expect": { "normalized": "/a/" }
19+
},
20+
{
21+
"name": "dot-dot-segment-preserved",
22+
"path": "/a/../b",
23+
"quirk": true,
24+
"description": "Dot segments are literal segment names in the virtual namespace; they are not collapsed and cannot alias another path.",
25+
"expect": { "normalized": "/a/../b" }
26+
},
27+
{
28+
"name": "dot-segment-preserved",
29+
"path": "/a/./b",
30+
"quirk": true,
31+
"expect": { "normalized": "/a/./b" }
32+
},
33+
{
34+
"name": "interior-double-slash-preserved",
35+
"path": "/a//b",
36+
"quirk": true,
37+
"description": "Interior empty segments survive normalization; such paths are non-canonical distinct keys.",
38+
"expect": { "normalized": "/a//b" }
39+
},
40+
{ "name": "case-preserved", "path": "/GitHub/Repos", "expect": { "normalized": "/GitHub/Repos" } }
41+
]
42+
}

0 commit comments

Comments
 (0)