diff --git a/config/config.example.yaml b/config/config.example.yaml index f7c89fcb8..b86df3ae7 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -277,7 +277,9 @@ defaults: # two blocks. `restrict` names what is CLOSED unless granted — an agent listed # here runs only for an actor whose grants hold agent:run: (or `all`); a # repo listed here is used only by an actor whose `repos` axis names it (or -# `all`). Everything unlisted is open to everyone who can reach the bot. +# `all`). Unlisted agents and code repos are open to human baselines. +# Credentials and schedules always need explicit agent actions and repo grants; +# `dispatch` alone grants neither. # Enforcement is at run time against the resolved agent/repo, so it can't be # bypassed via directives or config scopes. Names must be registered agents / # owner/name slugs, or the load fails. @@ -295,7 +297,9 @@ defaults: # axes; every axis is a list of names or the explicit word `all`, and an ABSENT # axis is the empty set (fail-closed). A `slack:` user holds the open chat # commands and every unrestricted agent whether listed or not — an entry ADDS to -# that; an Access browser session holds every group's read the same way; +# that; an Access browser session holds every group's read and unrestricted +# agents the same way. Both human baselines include open code repos without +# granting repository memory ownership; # credentials (`http:`, `mcp:`, `access:svc:`) hold exactly their entry and an # unlisted one holds nothing, not even `dispatch`. A surface entry is unioned # into every actor of its surface on top of its own entry — a person's entry diff --git a/deploy/cloudflare-memory/runs.test.ts b/deploy/cloudflare-memory/runs.test.ts index 45f1ad93b..840ae5f6b 100644 --- a/deploy/cloudflare-memory/runs.test.ts +++ b/deploy/cloudflare-memory/runs.test.ts @@ -1514,6 +1514,40 @@ describe("run history routes", () => { expect((second.data.items as Array<{ id: string }>).map((row) => row.id)).toEqual(["old-source"]); }); + it("repo complement filters match point and wire semantics in SQLite and reject malformed complements", async () => { + const key = storeKey(); + const now = Date.now(); + for (const [id, repo] of [ + ["open", "ACME/OPEN"], + ["closed", "Acme/Closed"], + ["missing", undefined], + ] as const) { + await putDirect(key, record(id, now - 1000, { events: events(1), ...(repo ? { repo } : {}) })); + } + const ids = async (visibleTo: unknown) => { + const res = await post("/runs/list", { storeKey: key, visibleTo }); + expect(res.status).toBe(200); + return (res.data.items as Array<{ id: string }>).map((r) => r.id).sort(); + }; + expect(await ids({ kind: "repos-not-in", repos: ["ACME/CLOSED"] })).toEqual(["open"]); + expect(await ids({ kind: "repos-not-in", repos: [] })).toEqual(["closed", "open"]); + expect(await ids({ kind: "repos-in", repos: ["acme/open"] })).toEqual(["open"]); + expect( + await ids({ + kind: "and", + of: [ + { kind: "repos-not-in", repos: ["acme/closed"] }, + { kind: "repos-in", repos: ["ACME/OPEN", "acme/closed"] }, + ], + }), + ).toEqual(["open"]); + for (const repos of ["all", [""], Array.from({ length: 95 }, (_, i) => `acme/r${i}`)]) { + expect((await post("/runs/list", { storeKey: key, visibleTo: { kind: "repos-not-in", repos } })).status).toBe( + 400, + ); + } + }); + it("list with `visibleTo` (authorization.md item 6): channels-in compiles to an indexed IN filter on the ONE LIMITed page query; visibility-in / user-is / or / and follow the same truth table as the in-memory store; none is an empty page; a bad filter is 400, never `all`", async () => { const key = storeKey(); const now = Date.now(); diff --git a/deploy/cloudflare-memory/worker.ts b/deploy/cloudflare-memory/worker.ts index 5ee288819..22887a958 100644 --- a/deploy/cloudflare-memory/worker.ts +++ b/deploy/cloudflare-memory/worker.ts @@ -7448,7 +7448,17 @@ function visibilitySql(f: RunVisibilityFilter, params: (string | number)[]): str case "visibility-in": return inList("channel_visibility", f.visibilities); case "repos-in": - return inList("repo", f.repos); + return inList( + "LOWER(repo)", + f.repos.map((r) => r.toLowerCase()), + ); + case "repos-not-in": + return f.repos.length === 0 + ? "repo IS NOT NULL" + : `(repo IS NOT NULL AND NOT (${inList( + "LOWER(repo)", + f.repos.map((r) => r.toLowerCase()), + )}))`; case "user-is": params.push(f.userId); return "user_id = ?"; @@ -7761,6 +7771,7 @@ function boundParameters(f: RunVisibilityFilter): number { case "visibility-in": return f.visibilities.length; case "repos-in": + case "repos-not-in": return f.repos.length; case "user-is": return 1; diff --git a/docs/decisions/0007-authorization-policy-table.md b/docs/decisions/0007-authorization-policy-table.md index 9194de11f..48eefc1f4 100644 --- a/docs/decisions/0007-authorization-policy-table.md +++ b/docs/decisions/0007-authorization-policy-table.md @@ -42,3 +42,15 @@ Configuration is one `grants` shape (actor id → actions, channels, repos, with ## Pattern A rules table for authorization, evaluated by one function. Strategy at the identity-resolution seam per surface. Fail-closed by construction. + +## Amended 2026-10-05 + +**Re-evaluation:** compile human code access without memory ownership. + +The original table separated identity from authority, but ConfigStore's open-unless-restricted shortcuts admitted ungranted agents and repos for machine credentials. The correction compiles openness into human namespace baselines and makes the two dispatch gates ask the existing policy table. A credential, including one bound to a person, holds only its configured grants; recursive delegation intersects authority on every axis. + +Code access needs a distinct compiled repo axis: the existing explicit `repos` axis also authorizes repository memory through `owner-of`. Giving it human openness would expose memory that was previously denied. Optional `repoAccess` inherits explicit `repos` for older/manual actors; human baselines add a finite complement of restricted repos. Configuration remains the same three literal axes. Union and intersection preserve code access and ownership independently and normalize repository case. + +The table adds the `repo-access` condition for `repo:use`; `owner-of` retains explicit ownership for repo memory and repo execution. The condition compiles to `repos-in` or bounded `repos-not-in`. A complement never matches a missing repo. Reference, wire and SQLite evaluators share those semantics; unknown filter kinds are rejected. Existing run-read rows do not emit complements, so their stored filters retain their prior shape. + +Rejected alternatives: widening ownership would change memory access; a user-kind or root-principal shortcut would exceed a bound credential or mixed delegation ceiling; closing global production resources would move credential safety into deployment configuration. The cost is finite-complement arithmetic in the existing model and one bounded store predicate, rather than a second policy mechanism. Regression proofs cover parsed credential scope, human defaults, bound admins, relays, nested delegation, memory ownership and SQLite parity. diff --git a/docs/public/screenshots/manifest/conductor-page.json b/docs/public/screenshots/manifest/conductor-page.json index 718afdc05..85fe99a98 100644 --- a/docs/public/screenshots/manifest/conductor-page.json +++ b/docs/public/screenshots/manifest/conductor-page.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -156,7 +158,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/costs-models.json b/docs/public/screenshots/manifest/costs-models.json index f76575fbc..388085792 100644 --- a/docs/public/screenshots/manifest/costs-models.json +++ b/docs/public/screenshots/manifest/costs-models.json @@ -75,7 +75,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -83,11 +83,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -148,7 +150,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/costs-users.json b/docs/public/screenshots/manifest/costs-users.json index d5b197304..528e37436 100644 --- a/docs/public/screenshots/manifest/costs-users.json +++ b/docs/public/screenshots/manifest/costs-users.json @@ -75,7 +75,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -83,11 +83,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -148,7 +150,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/costs.json b/docs/public/screenshots/manifest/costs.json index 5957db5ed..0f75ee5df 100644 --- a/docs/public/screenshots/manifest/costs.json +++ b/docs/public/screenshots/manifest/costs.json @@ -75,7 +75,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -83,11 +83,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -148,7 +150,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/home-conversation.json b/docs/public/screenshots/manifest/home-conversation.json index 6b0633d4d..a0af9af04 100644 --- a/docs/public/screenshots/manifest/home-conversation.json +++ b/docs/public/screenshots/manifest/home-conversation.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -155,7 +157,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/home-empty.json b/docs/public/screenshots/manifest/home-empty.json index de84cd16c..efe94cc5d 100644 --- a/docs/public/screenshots/manifest/home-empty.json +++ b/docs/public/screenshots/manifest/home-empty.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -155,7 +157,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/home-live.json b/docs/public/screenshots/manifest/home-live.json index a88d44bec..c63f7dd9b 100644 --- a/docs/public/screenshots/manifest/home-live.json +++ b/docs/public/screenshots/manifest/home-live.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -155,7 +157,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/metrics.json b/docs/public/screenshots/manifest/metrics.json index a7ea643d2..b6a9fe6ff 100644 --- a/docs/public/screenshots/manifest/metrics.json +++ b/docs/public/screenshots/manifest/metrics.json @@ -75,7 +75,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -83,11 +83,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -148,7 +150,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/plane.json b/docs/public/screenshots/manifest/plane.json index 6036aab18..0d2040dff 100644 --- a/docs/public/screenshots/manifest/plane.json +++ b/docs/public/screenshots/manifest/plane.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -155,7 +157,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/residents.json b/docs/public/screenshots/manifest/residents.json index 632bfb7af..92e56b1c2 100644 --- a/docs/public/screenshots/manifest/residents.json +++ b/docs/public/screenshots/manifest/residents.json @@ -77,7 +77,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -85,11 +85,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/review-page.json b/docs/public/screenshots/manifest/review-page.json index 99112ecfc..a7b79aa7f 100644 --- a/docs/public/screenshots/manifest/review-page.json +++ b/docs/public/screenshots/manifest/review-page.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -156,7 +158,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/run-page.json b/docs/public/screenshots/manifest/run-page.json index 5ca1aa4fb..977fddbd3 100644 --- a/docs/public/screenshots/manifest/run-page.json +++ b/docs/public/screenshots/manifest/run-page.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -156,7 +158,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/runs-index-viewing-as.json b/docs/public/screenshots/manifest/runs-index-viewing-as.json index b6e633ac9..6cdf02836 100644 --- a/docs/public/screenshots/manifest/runs-index-viewing-as.json +++ b/docs/public/screenshots/manifest/runs-index-viewing-as.json @@ -77,7 +77,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -85,11 +85,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/runs-index.json b/docs/public/screenshots/manifest/runs-index.json index e64f695db..5cd471f42 100644 --- a/docs/public/screenshots/manifest/runs-index.json +++ b/docs/public/screenshots/manifest/runs-index.json @@ -77,7 +77,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -85,11 +85,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/scheduled.json b/docs/public/screenshots/manifest/scheduled.json index bf8d70c1a..01a583c50 100644 --- a/docs/public/screenshots/manifest/scheduled.json +++ b/docs/public/screenshots/manifest/scheduled.json @@ -78,7 +78,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -86,11 +86,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/settings-channel.json b/docs/public/screenshots/manifest/settings-channel.json index 2eadc5526..f59550d63 100644 --- a/docs/public/screenshots/manifest/settings-channel.json +++ b/docs/public/screenshots/manifest/settings-channel.json @@ -77,7 +77,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -85,11 +85,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/settings-installation.json b/docs/public/screenshots/manifest/settings-installation.json index c9cdb65d1..a57f14eeb 100644 --- a/docs/public/screenshots/manifest/settings-installation.json +++ b/docs/public/screenshots/manifest/settings-installation.json @@ -77,7 +77,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -85,11 +85,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/settings-mcps.json b/docs/public/screenshots/manifest/settings-mcps.json index 1d19f36f8..8e2d175cf 100644 --- a/docs/public/screenshots/manifest/settings-mcps.json +++ b/docs/public/screenshots/manifest/settings-mcps.json @@ -77,7 +77,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -85,11 +85,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -151,7 +153,7 @@ "src/core/runLedger/inboxMessage.ts": "daac9f5ec1ae5a180d20cb589b5c0154c913e8c4b7b597f7907a7ffc2c0ba6ba", "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/unit-page-coding-only.json b/docs/public/screenshots/manifest/unit-page-coding-only.json index 26dbd187a..ba99ba0a1 100644 --- a/docs/public/screenshots/manifest/unit-page-coding-only.json +++ b/docs/public/screenshots/manifest/unit-page-coding-only.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -158,7 +160,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/unit-page.json b/docs/public/screenshots/manifest/unit-page.json index 295009e57..f72660412 100644 --- a/docs/public/screenshots/manifest/unit-page.json +++ b/docs/public/screenshots/manifest/unit-page.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -158,7 +160,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/unit-search-landed.json b/docs/public/screenshots/manifest/unit-search-landed.json index 27284aebf..b19bdc50c 100644 --- a/docs/public/screenshots/manifest/unit-search-landed.json +++ b/docs/public/screenshots/manifest/unit-search-landed.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -158,7 +160,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/public/screenshots/manifest/unit-search.json b/docs/public/screenshots/manifest/unit-search.json index 388c9ceea..93e2c81e3 100644 --- a/docs/public/screenshots/manifest/unit-search.json +++ b/docs/public/screenshots/manifest/unit-search.json @@ -80,7 +80,7 @@ "src/channels/webAssets.ts": "402b7a1bdb2c1967960dfc06673dca3e6dbaeb25718d1bdcb1f0c59302d19471", "src/channels/webSeed.ts": "c6db9be137ba5aa4f54687679d93762f967af4064e0211e01e7ba981db033199", "src/channels/webShell.ts": "c3f74aeb04010dd88204b86e7a782f5a45f6731bf6f0f29cc8a4b5039df8f090", - "src/config.ts": "40bba78c3137378eaddea54388ca3ccedf0090921d24144301b00daf9441f9af", + "src/config.ts": "128127cfff41596b15116aa941cbe4762ae0b6e5242a6b58ebf1e43aea40e2f2", "src/config/agents.ts": "ba7184ebcb6be04515a28e44246a471c5afc75b24bd212e9b4213ef46855c389", "src/config/profile.ts": "92e7edb8dfb5e4f6f255a00473c0aa436832b0797d54562b17ff96e3da49361c", "src/config/validate.ts": "3308b7fe8a6c0cb275532b238bdac4236b663dc5dd4d9e0cb2f0caf3ddd6e874", @@ -88,11 +88,13 @@ "src/core/answerOutcome.ts": "f52425f8c6cf107d358918faed8065a8f19d46350e0cfe77f581fb85b5a19520", "src/core/anthropicApiKey.ts": "d7dbb29f657da921981a4356527423c8640f8492d1890c097aa98eefa33f2f40", "src/core/audienceDecision.ts": "df56c7a8b56022eee0b185c463601ce276cce0a04dceff15e0832f6f13cc764a", - "src/core/authz/authorize.ts": "652bfe5132b3103a55b9d1490cbbcd638a338d4ac08e15445afe49040982f4a7", - "src/core/authz/grants.ts": "39927003f38a10d84b15b0656325dc337cb64c973ca20b043f1dd98870e509df", - "src/core/authz/policy.ts": "d36e4b92857fa3f5ff9e20b66cfbf78f112c0c5e628b3f28eb8d634aa4420736", + "src/core/authz/actor.ts": "1fb77d4cf0781b6a027dcebf8b9bd2c0ef94d03887fd6449c5947fc26304b89d", + "src/core/authz/authorize.ts": "3765d297702d46eb9a056652705b107cca31ec8b7447d02bc49fa275618ac220", + "src/core/authz/grantSets.ts": "92ddc2c565c69aecc96f5995f43b5805f74e6391435888fc580ab584e4e99b52", + "src/core/authz/grants.ts": "61f7fe1dca21045ce1e2b18adf5acdc02c98262dee114945f201e259e8009b7a", + "src/core/authz/policy.ts": "4c9e010002a779146d40be7319af73029171618cb2525f67dea9d61f3f560488", "src/core/authz/resource.ts": "8bf899b1c2f8e8db5650042b2af0f0a2359871c12685fd7dc7eaec6e1769d931", - "src/core/authz/types.ts": "58212afa0722cf062de5c2ef26fd356e9c3c707134e0718f0676159e680b8151", + "src/core/authz/types.ts": "5e02983014ffad24c0d0c4a7fe15d397d39c7138bc4f6f8528334622af4d558e", "src/core/authz/viewAs.ts": "4b162694a1a2c880df7f436027991d252f37318a44d8a5175685a3d81b0e63ec", "src/core/branchIdentityBaseline.ts": "b13b930408b3d87887736275b08ad0b7f740514082ca3b2b3f9a75c2f8ad5ead", "src/core/branchPublication.ts": "c4d749996d997e598993a46aadac6f386071e106bad1c52275ddddf03afec6a2", @@ -158,7 +160,7 @@ "src/core/runMetrics.ts": "3fcf56c30ffac9dedf68e901fba21fd12c021b94c5e79512905c6bcde73ec2ff", "src/core/runOwner.ts": "6e8f655ed2c34d08e3c258fc661309804e77d5d1b0972b5f12867427502b37cd", "src/core/runPace.ts": "5bec2b2a007c7db8461b6414fe18d94775bf93763336268c60fcc83177874e97", - "src/core/runRecord.ts": "a03ab7cba2695df08cb862d38f5b0fa2ddc932f4e29a35ca4f23eba901947a14", + "src/core/runRecord.ts": "633c0c829ebff85da0fffaabf9abb1f478c250668face2a603185659d6e9f689", "src/core/runShape.ts": "0962881b1f2201c02214b8ee6d897f279dc90f64feb9a57c3950b23e7ea8d1f2", "src/core/runStore.ts": "fcb9bcd6d37da0a6dad638b519c005afea665f6e755730aa22636a974e0547d2", "src/core/runStoreConstants.ts": "c9b1b1cc9b3d8532fce25f41bea2c49000d5a4debd6671d6f5139a8cba49632e", diff --git a/docs/reference/authorization.md b/docs/reference/authorization.md index b5deaa0a3..f2c23ea50 100644 --- a/docs/reference/authorization.md +++ b/docs/reference/authorization.md @@ -1,6 +1,6 @@ # Reference: authorization -Two blocks in `config.yaml` decide who may do what. `grants` says what each actor **holds**; `restrict` says what is **closed unless granted**. Everything else is open to whoever can reach the bot. One policy table (`authorize(actor, action, resource)`) reads the grants on every surface — Slack, CLI, HTTP, MCP, schedules — so nothing here can be bypassed by choosing a different way to ask. Enforcement is at run time against the *resolved* agent or repo, after directives, thread stickiness, and every config layer. +Two blocks in `config.yaml` decide who may do what. `grants` says what each actor **holds**; `restrict` says what is **closed unless granted**. Unlisted agents and code repos are open through human baselines. Credentials and jobs need explicit grants on every deployment. One policy table (`authorize(actor, action, resource)`) reads the grants on every surface — Slack, CLI, HTTP, MCP, schedules — so nothing here can be bypassed by choosing a different way to ask. Enforcement is at run time against the *resolved* agent or repo, after directives, thread stickiness, and every config layer. ## `grants` @@ -30,14 +30,14 @@ Keyed by platform-namespaced actor id. Three axes, each a list of names or the e |---|---|---| | `actions` | `:read` / `:write` / `:exec` for every command group (`runs`, `friction`, `repo`, `config`, `memory`, `mcp`, `schedule`, `deploy`, `env`, `setup`, `help`), `agent:run:`, `dispatch`, `deploy:write` | which commands and agents the actor may run | | `channels` | channel ids (`slack:C…`, `http:`, `mcp:`) | whose runs the actor may read beyond the public ones | -| `repos` | `owner/name` slugs (case-insensitive) | which restricted repos the actor may use | +| `repos` | `owner/name` slugs (case-insensitive) | which repos a credential may use and whose repo memory it owns; adds to human code access | ### Baselines — what an id holds listed or not | Actor id | Baseline | A `grants` entry … | |---|---|---| -| `slack:U…` (a Slack user) | the open chat commands (`help`/`config`/`repo`/`friction`/`memory`/`mcp`/`schedule` reads, `memory:write`, `mcp:write`) plus `agent:run:` for every agent not under `restrict.agents` | **adds** to the baseline | -| `access:` (a browser signed in through Access) | every group's `read`, plus `memory:write` and `mcp:write` for its own scope (the web chat makes a signed-in browser a chat user) | **adds** to the baseline | +| `slack:U…` (a Slack user) | the open chat commands (`help`/`config`/`repo`/`friction`/`memory`/`mcp`/`schedule` reads, `memory:write`, `mcp:write`) plus unrestricted code repos and `agent:run:` for every agent not under `restrict.agents` | **adds** to the baseline | +| `access:` (a browser signed in through Access) | every group's `read`, unrestricted agents and code repos, plus `memory:write` and `mcp:write` for its own scope (the web chat makes a signed-in browser a chat user) | **adds** to the baseline | | `access:svc:` (an Access service token) | nothing | is **exactly** what it holds | | `http:` / `mcp:` (an ingress token) | nothing | is **exactly** what it holds | | `schedule:` (a cron firing) | what the schedule registry declares for it | **replaces** the declaration | @@ -89,3 +89,10 @@ Both lists are optional and independent. An agent or repo **not** listed is open - [How-to: restrict who can do what](../how-to/restrict-who-can-do-what.md) — the narrative version, building up from open to locked down. - [Explanation: execution and trust](../explanation/execution-and-trust.md) — why `repo:write` in particular is never a baseline. - [Spec: authorization](specs/authorization.md) — the policy table, the actor model, and the proofs. + + +## Migrating scoped credentials + +Before deploying the scope fix, grant each HTTP/MCP/service or schedule actor the agent actions and repos it is intended to use. `dispatch` admits a request at ingress; it does not grant an agent or repository. For example, a review credential needs `actions: [dispatch, agent:run:review]` and `repos: [acme/payments]`. A credential bound to a person keeps this ceiling, including when the person is an admin. Registry-only, plan-runner and deployment credentials keep their existing command grants. + +Human open code access is compiled independently of explicit repo memory ownership. Neither a human open default nor a delegated human identity grants a credential additional repo memory access. Code and memory grants intersect separately through nested delegation. diff --git a/docs/reference/specs/authorization.md b/docs/reference/specs/authorization.md index 961c8d2d0..75f65b922 100644 --- a/docs/reference/specs/authorization.md +++ b/docs/reference/specs/authorization.md @@ -2,7 +2,7 @@ One decision, `authorize(actor, action, resource) → allow | deny(reason)`, over a policy table of data rows, is the only mechanism that decides what a caller may do. Identity is resolved **once per surface** into a typed `Actor`; every gate is a **policy row**; channel visibility is the **relation `member-of(actor, channel)`** and holds on every surface — Slack, HTTP ingress, MCP, the Access-gated `/api`, the CLI, the schedule shim, and memory reflection. Machines get their own gated identities, and a token must not read another team's runs (a denied read is `not_found`). The weekly self-improvement cron seeing zero runs, an Access operator reading a private-channel run, and a private-conversation fact becoming `org` memory are the three leaks the model closes — as consequences of one table, not as special cases. -- **Code**: [`src/core/authz/viewAs.ts`](../../../src/core/authz/viewAs.ts) (`holdsAll`, `isViewablePerson`, `viewingRefusal` — the words every surface of view-as agrees on, item 17), [`src/channels/viewAs.ts`](../../../src/channels/viewAs.ts) (the `sb-view-as` cookie, its two routes, `requestersOf`, `refuseWhileViewing`), [`src/core/authz/types.ts`](../../../src/core/authz/types.ts) (the shared contract: `Actor`, `ActorKind`, `Grants`/`GrantSet`/`NO_GRANTS`, `Action`, `Resource`/`ResourceType`, `ChannelVisibility`, `Condition`, `Rule`, `Decision`, `Predicate`, `ChannelDirectory`); [`src/core/authz/policy.ts`](../../../src/core/authz/policy.ts) (the table + module-load validation); [`src/core/authz/authorize.ts`](../../../src/core/authz/authorize.ts) (the one public decision); [`src/core/authz/predicate.ts`](../../../src/core/authz/predicate.ts) (`predicateFor`, store-side only); [`src/core/authz/grants.ts`](../../../src/core/authz/grants.ts) (`grantsFor`, the `grants` + `restrict` blocks, the namespace baselines, `mayRunAgent` / `mayUseRepo`); [`src/core/authz/actor.ts`](../../../src/core/authz/actor.ts) (per-surface actor resolution, `chatActorOf` — the actor every dispatch gate decides on); [`src/core/ingressTokens.ts`](../../../src/core/ingressTokens.ts) (the one token-map parser: `{ subject, channel?, email? }`); [`src/channels/requester.ts`](../../../src/channels/requester.ts) (`boundRequester`: a credential bound to a person, item 15); [`src/core/authz/channelDirectory.ts`](../../../src/core/authz/channelDirectory.ts) (the seam and its static id mapping, `StaticChannelDirectory`); [`src/channels/slackChannelDirectory.ts`](../../../src/channels/slackChannelDirectory.ts) (`SlackChannelDirectory`: `conversations.info` behind the seam, TTL cache); [`src/core/memory/reflection.ts`](../../../src/core/memory/reflection.ts) (the memory write gate, `reflectionActor`). Adapter touch points: [`src/core/commandChat.ts`](../../../src/core/commandChat.ts) (chat actor), [`src/channels/mcp.ts`](../../../src/channels/mcp.ts) (token actor), [`src/channels/commandHttp.ts`](../../../src/channels/commandHttp.ts) (`accessActor`: the Access browser / service-token actor — the one resolver for every surface the Access gate fronts), [`src/channels/liveView.ts`](../../../src/channels/liveView.ts) (the `/runs` pages decide with that actor: the index through `predicateFor`, a tokenless run read through `authorize`), [`src/index.ts`](../../../src/index.ts) (the Slack directory wired as the dispatcher's `channelDirectory` once the Slack adapter is up; the Access actor handed to the live-view handler), [`src/core/dispatch/record.ts`](../../../src/core/dispatch/record.ts) (the visibility stamp `channelVisibilityOf`, bounded by `CHANNEL_DIRECTORY_TIMEOUT_MS`; `RecordDeps` is the slice of `CoreDeps` it reads), [`src/core/schedules.ts`](../../../src/core/schedules.ts) (schedule actors declared in the registry), [`src/config.ts`](../../../src/config.ts) (`grants` schema; item 18: the `github` key, `clearUserOverride` preserving it, `userGithubBinding`), [`src/config/validate.ts`](../../../src/config/validate.ts) (`validateConfig` rules; item 18: the binding's shape and duplicate rules), [`src/execution/authorBinding.ts`](../../../src/execution/authorBinding.ts) (item 18: `resolveLogin`, `resolveById`, `bindingOf` — the binding's reads), [`src/core/authz/pointingActor.ts`](../../../src/core/authz/pointingActor.ts) (the pointing actor the `conversation:read` row is asked for). +- **Code**: [`src/core/authz/viewAs.ts`](../../../src/core/authz/viewAs.ts) (`holdsAll`, `isViewablePerson`, `viewingRefusal` — the words every surface of view-as agrees on, item 17), [`src/channels/viewAs.ts`](../../../src/channels/viewAs.ts) (the `sb-view-as` cookie, its two routes, `requestersOf`, `refuseWhileViewing`), [`src/core/authz/types.ts`](../../../src/core/authz/types.ts) (the shared contract: `Actor`, `ActorKind`, `Grants`/`GrantSet`/`NO_GRANTS`, `Action`, `Resource`/`ResourceType`, `ChannelVisibility`, `Condition`, `Rule`, `Decision`, `Predicate`, `ChannelDirectory`); [`src/core/authz/policy.ts`](../../../src/core/authz/policy.ts) (the table + module-load validation); [`src/core/authz/authorize.ts`](../../../src/core/authz/authorize.ts) (the one public decision); [`src/core/authz/predicate.ts`](../../../src/core/authz/predicate.ts) (`predicateFor`, store-side only); [`src/core/authz/grants.ts`](../../../src/core/authz/grants.ts) (`grantsFor`, the `grants` + `restrict` blocks, the compiled namespace baselines); [`src/core/authz/actor.ts`](../../../src/core/authz/actor.ts) (per-surface actor resolution, `chatActorOf` — the actor every dispatch gate decides on); [`src/core/ingressTokens.ts`](../../../src/core/ingressTokens.ts) (the one token-map parser: `{ subject, channel?, email? }`); [`src/channels/requester.ts`](../../../src/channels/requester.ts) (`boundRequester`: a credential bound to a person, item 15); [`src/core/authz/channelDirectory.ts`](../../../src/core/authz/channelDirectory.ts) (the seam and its static id mapping, `StaticChannelDirectory`); [`src/channels/slackChannelDirectory.ts`](../../../src/channels/slackChannelDirectory.ts) (`SlackChannelDirectory`: `conversations.info` behind the seam, TTL cache); [`src/core/memory/reflection.ts`](../../../src/core/memory/reflection.ts) (the memory write gate, `reflectionActor`). Adapter touch points: [`src/core/commandChat.ts`](../../../src/core/commandChat.ts) (chat actor), [`src/channels/mcp.ts`](../../../src/channels/mcp.ts) (token actor), [`src/channels/commandHttp.ts`](../../../src/channels/commandHttp.ts) (`accessActor`: the Access browser / service-token actor — the one resolver for every surface the Access gate fronts), [`src/channels/liveView.ts`](../../../src/channels/liveView.ts) (the `/runs` pages decide with that actor: the index through `predicateFor`, a tokenless run read through `authorize`), [`src/index.ts`](../../../src/index.ts) (the Slack directory wired as the dispatcher's `channelDirectory` once the Slack adapter is up; the Access actor handed to the live-view handler), [`src/core/dispatch/record.ts`](../../../src/core/dispatch/record.ts) (the visibility stamp `channelVisibilityOf`, bounded by `CHANNEL_DIRECTORY_TIMEOUT_MS`; `RecordDeps` is the slice of `CoreDeps` it reads), [`src/core/schedules.ts`](../../../src/core/schedules.ts) (schedule actors declared in the registry), [`src/config.ts`](../../../src/config.ts) (`grants` schema; item 18: the `github` key, `clearUserOverride` preserving it, `userGithubBinding`), [`src/config/validate.ts`](../../../src/config/validate.ts) (`validateConfig` rules; item 18: the binding's shape and duplicate rules), [`src/execution/authorBinding.ts`](../../../src/execution/authorBinding.ts) (item 18: `resolveLogin`, `resolveById`, `bindingOf` — the binding's reads), [`src/core/authz/pointingActor.ts`](../../../src/core/authz/pointingActor.ts) (the pointing actor the `conversation:read` row is asked for). - **Tests**: [`src/channels/viewAs.test.ts`](../../../src/channels/viewAs.test.ts) (item 17: the cookie, the routes, the refusal), [`src/core/authz/policy.test.ts`](../../../src/core/authz/policy.test.ts), [`src/core/authz/authorize.test.ts`](../../../src/core/authz/authorize.test.ts), [`src/core/authz/predicate.test.ts`](../../../src/core/authz/predicate.test.ts), [`src/core/authz/grants.test.ts`](../../../src/core/authz/grants.test.ts), [`src/core/authz/actor.test.ts`](../../../src/core/authz/actor.test.ts), [`src/core/authz/channelDirectory.test.ts`](../../../src/core/authz/channelDirectory.test.ts), [`src/channels/slackChannelDirectory.test.ts`](../../../src/channels/slackChannelDirectory.test.ts); the adapter tests [`src/core/commandChat.test.ts`](../../../src/core/commandChat.test.ts), [`src/channels/mcp.test.ts`](../../../src/channels/mcp.test.ts), [`src/channels/commandHttp.test.ts`](../../../src/channels/commandHttp.test.ts), [`src/core/ingressTokens.test.ts`](../../../src/core/ingressTokens.test.ts), [`src/channels/requester.test.ts`](../../../src/channels/requester.test.ts), [`src/execution/authorBinding.test.ts`](../../../src/execution/authorBinding.test.ts) (item 18), [`src/channels/liveView.test.ts`](../../../src/channels/liveView.test.ts), [`src/channels/slack.test.ts`](../../../src/channels/slack.test.ts), [`src/core/schedules.test.ts`](../../../src/core/schedules.test.ts), [`src/config.test.ts`](../../../src/config.test.ts); the store side [`src/core/commands/runs.test.ts`](../../../src/core/commands/runs.test.ts), [`src/core/commands/friction.test.ts`](../../../src/core/commands/friction.test.ts), [`src/core/frictionLedger.test.ts`](../../../src/core/frictionLedger.test.ts), [`src/core/memory/reflection.test.ts`](../../../src/core/memory/reflection.test.ts); the regression net [`src/core/commandConformance.test.ts`](../../../src/core/commandConformance.test.ts) (actor kind × action × resource enumerated from the table), [`src/core/authz/pointingActor.test.ts`](../../../src/core/authz/pointingActor.test.ts); the click (item 16) [`src/core/dispatch/confirm.test.ts`](../../../src/core/dispatch/confirm.test.ts) and the confirmation describe of [`src/core/dispatcher.test.ts`](../../../src/core/dispatcher.test.ts). ## Behavior @@ -10,17 +10,17 @@ One decision, `authorize(actor, action, resource) → allow | deny(reason)`, ove 1. **Actors — resolved by the surface, never trusted from the request.** An `Actor` is `{ kind, id, grants, onBehalfOf?, origin?, self?, asUser? }` — `self` the ids that mean "me" (the actor's own, and the person a dashboard session is linked to, [record 0042](../../decisions/0042-a-dashboard-session-is-the-person-its-email-names-identity-not-authority.md)), read by `is-self`, `acts-as-person`, the `me` tier and the "mine" filters and never by a grant check; `asUser` that person, for display and the audit line. `kind ∈ { user, service, schedule, agent }`; `id` is platform-namespaced (invariant 4) and is the ONE identity every later decision keys on: `slack:U…` (a Slack user), `http:` (an ingress token's subject), `mcp:` (the same token over MCP), `access:` (a Cloudflare Access browser session), `access:svc:` (an Access service token), `cli:local`, `schedule:`, `agent:`. Each adapter returns exactly what it can prove and nothing more: Slack the user id of the event; HTTP/MCP the token entry the bearer matched; the Access gate the verified JWT's `sub` or `common_name`; the CLI the fixed local identity; the schedule shim the registry entry that fired. **Grants are never attached by the adapter**: one resolver, `grantsFor(actor, config)`, reads them from configuration (item 9), so two surfaces resolving the same id get the same grants. `origin` (a chat actor's `{ channelId, threadKey }`) is context for the channel-scoped commands and the channel memory scope — never authority ([command-registry.md](command-registry.md) item 21). An `agent` actor carries `onBehalfOf`, the principal whose [run](../vocabulary.md#run) it is; its **effective grants are the intersection** of its own and the principal's, computed per `GrantSet` (`"all" ∩ S = S`, `S ∩ T` set-wise) — an [agent](../vocabulary.md#agent) never exceeds the person it acts for. An actor the adapter cannot resolve is `NO_GRANTS` on every axis. Adapters make **no authorization decision**: no scope comparison, no channel pin, no gate resolver — building the `Actor` is the whole of their part. 2. **Actions — one closed vocabulary.** An `Action` is a command's effect class `:read | :write | :exec` (`runs:read`, `friction:write`, `repo:exec`, …), or one of the non-command actions: `agent:run:` (start the named agent — decided in `dispatch()`, invariant 3; `agent:run:*` = every agent), `repo:use` (act inside a repo's resident environment), `main-task:stop` (the requester stops only work bound to their verified main-thread act), `memory:write` (reflection writing a fact to a scope), `schedule:fire` (the schedule shim's actor firing a schedule), `coordinator:step` (a ship coordinator's step — `spawn`, `read-record`, `pr-check`, the instance creation — held by the `coordinator` ingress bearer's `service` actor and admitted to no other kind, an admin's `all` included: the steps are a program's, and the child a spawn starts is authorized as the requesting user the parent record names, never as the holder of this grant; [http-ingress.md](http-ingress.md) item 9), and `plan:merge` (the plan runner's merge of a [unit](../vocabulary.md#unit)'s pull request — [record 0031](../../decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md)'s merge grant, held by the same bearer by name, a `service` actor and no person, an admin's `all` included: what decides a merge is the branch and the guards, never the requester, and withdrawing the grant returns every merge to a person; the `merge` step of [http-ingress.md](http-ingress.md) item 9 decides it beside `coordinator:step`). A command declares its `action` once in `defineCommand`; `write` never implies `exec`, and no action authorizes a run. 3. **Resources — typed, with the attributes rules read.** `run { id, channelId, userId, repo?, channelVisibility? }`; `channel { id, visibility }`; `memory-scope { key, kind: org | user | repo | channel, originChannelVisibility? }`; `repo { owner, name }`; `config-scope { kind: channel, id, visibility? }` (the channel's own visibility, read by `member-of`'s public half when the scope is read from another channel; absent → never public), `config-scope { kind: user, id }`, `config-scope { kind: thread, id }` (`id` the [thread](../vocabulary.md#thread) key — the thread scope `config set thread` writes, [routing-and-config.md](routing-and-config.md) item 27) and `config-scope { kind: org }` (channel, user and org are the three config tiers, where MCP servers live too); `agent { name }`; and `command { id }` for list-shaped actions with no single resource (`runs.list`, `friction.report`) and for every command whose definition resolves no other resource. `ChannelVisibility ∈ { public, private, dm, machine, unknown }`; `machine` is every `http:*` / `mcp:*` channel, whose members are the tokens granted it. A handler names the resource it is about to touch; nothing about a resource is inferred from the request. -4. **The policy table — data, closed vocabulary, validated at load.** A `Rule` is `{ action, resource, actorKinds?, resourceKind, originVisibility?, when: Condition[] }`. Within a row `when` is **ANDed**; rows for the same `(action, resource)` are **ORed**; **no matching row is a deny**. A row also carries one-sided **selectors** — `actorKinds` (the actor's kind), `resourceKind` (a `memory-scope`'s `org | user | repo | channel` or a `config-scope`'s `channel | user | org` — REQUIRED on a row for a kinded resource type and FORBIDDEN on every other, typed per resource as `KindOf` and enforced by the `Rule` type: a `memory-scope` row without a kind or a `run` row with one does not compile), `originVisibility` (the resource's origin channel visibility) — that pick WHICH rows apply to a request and read one side only, as distinct from the five **conditions** below, which relate actor to resource; the org-write rule (item 8) is an `originVisibility: [public]` selector on the `memory:write` × `memory-scope { kind: org }` row — a point-check-only row, never compiled into a list predicate. The condition vocabulary is CLOSED: `has-grant(g)` (`actor.grants.actions` contains `g` or is `"all"`), `member-of` — ONE definition, used by both evaluators: it holds when `actor.grants.channels` contains the resource's channel id (or is `"all"`), OR the actor's `memberOf` contains it (the channels the channel directory says the actor's PERSON is in, resolved once with the actor — a fact about the person, never a grant: item 7), OR the channel's visibility is `public` (`resource.channelVisibility`, stamped on the run at dispatch; `unknown` is never public). Channels are channels: there is no machine-channel vocabulary, no channel-prefix rule — an `http:`/`mcp:` channel is a member's channel exactly when the grant names it. `memberOf` is read from the root principal like `self` (an agent on behalf of a person reads through the person's membership and still needs its own `has-grant`), and no grant check reads it: an actor with and without `memberOf` holds the same effective grants and passes the same command rows — `is-self` (`resource.userId` equals the actor's id, or the `onBehalfOf` principal's), `owner-of` (`actor.grants.repos` contains the resource's `owner/name`, or is `"all"`), `all-channels` (`actor.grants.channels === "all"`). Every member is both evaluable against one resource and compilable to a store predicate — that is the admission test for a condition; a need for an arbitrary predicate is a plan-level stop, not a local addition. The table is validated when the module loads: a rule naming an unknown condition kind, an unknown resource type, or an action no command declares throws at import, so a malformed table can never serve a request. Every admission is a row, one vocabulary for humans and machines: every command row is ` command [has-grant()]`, and what differed by surface is now what each actor HOLDS (item 9) — `open` → the actions of those commands are the baseline every Slack user holds (`CHAT_OPEN_ACTIONS`: `help|config|repo|friction|memory|mcp|schedule:read`, `memory:write`, `mcp:write`), a browser session every `:read`, a token exactly its scopes, so a `dispatch`-only token is refused by the same row that admits a Slack user; `operator` → `has-grant(:write)` (or `:read` for the read commands) — the grant the admins' `all` translation and an operator's grants carry; `repoManager` → `has-grant(repo:write)` (and `friction:write` for `friction propose`); `channelConfig` → `has-grant(config:write)` on `config-scope { channel }`, asked by the `config.*` handlers for the `channel` scope (the `config:write` grant: never a baseline — its holders, admins, Access operators, and tokens minted with it) — membership is NOT a condition of that row: a chat user may target another channel with `--channel`, and no adapter proves channel membership until the directory (item 7's gap), so a `member-of` there would deny every non-admin `config set channel`; the `thread` scope (`config set thread`, [routing-and-config.md](routing-and-config.md) item 27) is the same right on `config-scope { thread }` — one `has-grant(config:write)` row, so whoever may set the channel may set a thread in it and membership alone admits nobody; the command itself has two `config:write` rows on `command` — `actorKinds: [user]` with no condition (a person always has their own scope to write) and `has-grant(config:write)` (a credential needs the grant); READING another channel's scope — its instructions text included — is the table's decision too: two `config:read` rows on `config-scope { channel }`, asked by `config show --channel` and by the instructions peek (`config instructions channel` with no text) for a named target — `has-grant(config:write)` for the caller's own actor (whoever may set a channel's scope may read it; the actor's channel grants and a public channel admit through `member-of` as well) and `member-of` for the pointing actor of item 13 (one membership, the origin, no grants), so a public channel's scope is readable from anywhere, a private one only from inside it or by grant, and `unknown` (no directory, a failed or slow lookup) like a private one; the visibility is the channel directory's, bounded like the run stamp (item 7), not looked up for the caller's own channel; one refusal text for private, DM, unknown and nonexistent alike; `agentRun` → `repo test|build` resolve the resource `agent { coding }` (`CommandDef.resource`), decided by `repo:exec agent [has-grant(agent:run:{name})]` (the right to run the implicit target agent) or `repo:exec agent [has-grant(repo:exec)]` (the exec grant a token was minted with) — which agents an actor may run is an action, not a grant axis (`Grants` has exactly three: `actions`, `channels`, `repos`). The MCP tiers are `mcp:write` rows on `config-scope { channel }` (`has-grant(config:write)`, or `has-grant(mcp:write)` for a `service`) and `config-scope { org }` (`has-grant(repo:write)`, or `has-grant(mcp:write)` for a `service`); forgetting a shared memory record is the `repo:write` right. A run read is `runs:read` ∧ (`member-of` ∨ `all-channels`); a `run` resource with `is-self` is how a DM run belongs to its user. +4. **The policy table — data, closed vocabulary, validated at load.** A `Rule` is `{ action, resource, actorKinds?, resourceKind, originVisibility?, when: Condition[] }`. Within a row `when` is **ANDed**; rows for the same `(action, resource)` are **ORed**; **no matching row is a deny**. A row also carries one-sided **selectors** — `actorKinds` (the actor's kind), `resourceKind` (a `memory-scope`'s `org | user | repo | channel` or a `config-scope`'s `channel | user | org` — REQUIRED on a row for a kinded resource type and FORBIDDEN on every other, typed per resource as `KindOf` and enforced by the `Rule` type: a `memory-scope` row without a kind or a `run` row with one does not compile), `originVisibility` (the resource's origin channel visibility) — that pick WHICH rows apply to a request and read one side only, as distinct from the **conditions** below, which relate actor to resource; the org-write rule (item 8) is an `originVisibility: [public]` selector on the `memory:write` × `memory-scope { kind: org }` row — a point-check-only row, never compiled into a list predicate. The condition vocabulary is CLOSED: `has-grant(g)` (`actor.grants.actions` contains `g` or is `"all"`), `member-of` — ONE definition, used by both evaluators: it holds when `actor.grants.channels` contains the resource's channel id (or is `"all"`), OR the actor's `memberOf` contains it (the channels the channel directory says the actor's PERSON is in, resolved once with the actor — a fact about the person, never a grant: item 7), OR the channel's visibility is `public` (`resource.channelVisibility`, stamped on the run at dispatch; `unknown` is never public). Channels are channels: there is no machine-channel vocabulary, no channel-prefix rule — an `http:`/`mcp:` channel is a member's channel exactly when the grant names it. `memberOf` is read from the root principal like `self` (an agent on behalf of a person reads through the person's membership and still needs its own `has-grant`), and no grant check reads it: an actor with and without `memberOf` holds the same effective grants and passes the same command rows — `is-self` (`resource.userId` equals the actor's id, or the `onBehalfOf` principal's), `repo-access` (compiled code access covers the resource repo, independently of memory ownership), `owner-of` (`actor.grants.repos` contains the resource's `owner/name`, or is `"all"`), `all-channels` (`actor.grants.channels === "all"`). Every member is both evaluable against one resource and compilable to a store predicate — that is the admission test for a condition; a need for an arbitrary predicate is a plan-level stop, not a local addition. The table is validated when the module loads: a rule naming an unknown condition kind, an unknown resource type, or an action no command declares throws at import, so a malformed table can never serve a request. Every admission is a row, one vocabulary for humans and machines: every command row is ` command [has-grant()]`, and what differed by surface is now what each actor HOLDS (item 9) — `open` → the actions of those commands are the baseline every Slack user holds (`CHAT_OPEN_ACTIONS`: `help|config|repo|friction|memory|mcp|schedule:read`, `memory:write`, `mcp:write`), a browser session every `:read`, a token exactly its scopes, so a `dispatch`-only token is refused by the same row that admits a Slack user; `operator` → `has-grant(:write)` (or `:read` for the read commands) — the grant the admins' `all` translation and an operator's grants carry; `repoManager` → `has-grant(repo:write)` (and `friction:write` for `friction propose`); `channelConfig` → `has-grant(config:write)` on `config-scope { channel }`, asked by the `config.*` handlers for the `channel` scope (the `config:write` grant: never a baseline — its holders, admins, Access operators, and tokens minted with it) — membership is NOT a condition of that row: a chat user may target another channel with `--channel`, and no adapter proves channel membership until the directory (item 7's gap), so a `member-of` there would deny every non-admin `config set channel`; the `thread` scope (`config set thread`, [routing-and-config.md](routing-and-config.md) item 27) is the same right on `config-scope { thread }` — one `has-grant(config:write)` row, so whoever may set the channel may set a thread in it and membership alone admits nobody; the command itself has two `config:write` rows on `command` — `actorKinds: [user]` with no condition (a person always has their own scope to write) and `has-grant(config:write)` (a credential needs the grant); READING another channel's scope — its instructions text included — is the table's decision too: two `config:read` rows on `config-scope { channel }`, asked by `config show --channel` and by the instructions peek (`config instructions channel` with no text) for a named target — `has-grant(config:write)` for the caller's own actor (whoever may set a channel's scope may read it; the actor's channel grants and a public channel admit through `member-of` as well) and `member-of` for the pointing actor of item 13 (one membership, the origin, no grants), so a public channel's scope is readable from anywhere, a private one only from inside it or by grant, and `unknown` (no directory, a failed or slow lookup) like a private one; the visibility is the channel directory's, bounded like the run stamp (item 7), not looked up for the caller's own channel; one refusal text for private, DM, unknown and nonexistent alike; `agentRun` → `repo test|build` resolve the resource `agent { coding }` (`CommandDef.resource`), decided by `repo:exec agent [has-grant(agent:run:{name})]` (the right to run the implicit target agent) or `repo:exec agent [has-grant(repo:exec)]` (the exec grant a token was minted with) — which agents an actor may run is an action, not a grant axis (`Grants` has exactly three: `actions`, `channels`, `repos`). The MCP tiers are `mcp:write` rows on `config-scope { channel }` (`has-grant(config:write)`, or `has-grant(mcp:write)` for a `service`) and `config-scope { org }` (`has-grant(repo:write)`, or `has-grant(mcp:write)` for a `service`); forgetting a shared memory record is the `repo:write` right. A run read is `runs:read` ∧ (`member-of` ∨ `all-channels`); a `run` resource with `is-self` is how a DM run belongs to its user. 5. **`authorize` — the one public decision.** `authorize(actor, action, resource)` is the ONLY export command code, `dispatch()`, and reflection call; it evaluates the rows for `(action, resource.type)` against the actor's effective grants and returns `{ allow: true }` or `{ allow: false, reason }`. The command registry asks it FIRST in `invoke` on every surface — `authorize(caller.actor, cmd.action, resource)`, where a command declares its `action` once and, when the resource is not the command itself, a `resource(rawInput, caller)` resolver (`repo.test|build` → `agent { coding }`; everything else `command { id }`) — before parsing, so a refused caller learns nothing about the schema. The `reason` names the failed condition (`no rule for runs:read on run`, `not a member of slack:C…`, `missing grant friction:write`) and goes to the **audit line only** ([command-registry.md](command-registry.md) item 7); a reply never carries it. A denied **point read of a resource** (`runs get `, `runs events`, `runs friction`, `runs stop`, and the tokenless run page with its events, friction and stop routes) renders as `not_found`, byte-identical to a run that does not exist — existence is never revealed. A denied write or list is `unauthorized` with the shared 🚫 wording, whose golden texts are unchanged. Handlers never compare ids or channels by hand: `grep -rn "caller.channel\|chatGate" src/` outside `src/core/authz/` is empty. **A child run is authorized as the requesting user** ([agent-conductor.md](agent-conductor.md); [routing-and-config.md](routing-and-config.md) item 20): a run a conductor spawns is a `dispatch()` run whose message carries the requester's own `userId`, so the agent gate, the profile gate and the repository gates are asked of the child with that person's grants — never the parent run's, which holds none of its own — and a child gives a requester nothing they could not start by hand; the run tools' reads (`list_runs`, `get_run_status`) are `predicateFor` and `authorize` under the same actor, exactly as `runs list` and `runs get`, a deny `not_found` like any point read; the session tools ([session-log.md](session-log.md) item 10) make the same point read on the run itself before touching its log, and a deny reads as an empty result — no hits, no turn — which is what a log with nothing in it answers too. 6. **List reads — the same rules, pushed into the store.** `predicateFor(actor, action, resourceType)` compiles the rows for a list-shaped action into a `Predicate` — `none`, `all`, `channels-in(ids)`, `repos-in(repos)`, `user-is(id)`, `visibility-in(visibilities)`, or an `and` / `or` of those (a row's ANDed conditions → `and`, the ORed rows → `or`) — from the SAME table `authorize` reads: `all-channels` → `all`; `member-of` → `or(channels-in(actor.grants.channels ∪ actor.memberOf), visibility-in(["public"]))` — the same definition as item 4 in the same vocabulary (one `channels-in` leaf, wider by the person's channels), so the run store filters on the stamped `channelVisibility` column and a `public` run is listed for everyone (the `visibility-in` leaf is the one addition to the contract's `Predicate`, landing with the store wiring); `owner-of` → `repos-in(actor.grants.repos)`; `is-self` → `user-is`; no matching row → `none`. It is callable only from the store adapters (`RunsService`, `RunStoreFrictionLedger`, the memory stores), which hand it to the store as its own filter: `RunListOptions.visibleTo` is the predicate's wire form (`RunVisibilityFilter` in `runRecord.ts` — the same tree with its sets as arrays), the in-memory and file stores evaluate it with `matchesVisibility` (the one truth table), the run-history DO compiles it to SQL (`channel_id IN (…)`, `channel_visibility IN (…)`, `user_id = ?`, `repo IN (…)`, each on an index ordered like the page — never a full scan), `none` never touches the store, and `all` sends no filter — so a list returns only what a point read would have allowed and **no surface loads records and filters afterwards**. A store that receives a malformed filter answers 400, never `all`. A denied list is an empty page, not an error. The invariant that makes this safe is the **`predicate ⇔ authorize` differential**: for any actor and any fixture of records, filtering by the store predicate equals filtering by point `authorize` on each record — the load-bearing test of the module. `runs list` without a store predicate (`predicateFor` returns `none` for an unknown actor) lists nothing. The "mine" filter every list surface offers (`runs list --mine`, `/runs?mine=1`) is `allOf([predicateFor(actor, …), ownedBy(actor)])` — `ownedBy` one `user-is` per id in the actor's `self` set, ORed — a narrowing of the readable predicate, never a second decision: a run the policy hides stays hidden, and an actor no run is requested as (an unlinked dashboard session, a service token) owns nothing. 7. **Channel visibility — a relation, on every surface.** `member-of(actor, channel)` and the `all-channels` grant are the two ways a run is readable; the Access API is bound like every other surface (an operator without `all-channels` gets `not_found` on a private-channel run they are not a member of), and so are the `/runs` pages: `src/index.ts` resolves the verified Access identity with the same `accessActor` the `/api` adapter uses and hands it to the live-view handler, whose index lists (the default live rows, `?all=1`, the `?stream=1` feed, the Scheduled tab's live links) through `predicateFor(actor, "runs:read", "run")` and whose tokenless finished-run routes (page, events, friction, and the 409 a tokenless stop gives a finished run) `authorize` the run's own attributes — each after the `runs:read` command admission `/api/runs.list` / `runs.get` ask first, so the same identity sees on the page exactly what the API answers, and a deny is the same 404 as an unknown id with the reason on the audit line only. The capability-token live page is untouched: the token IS the capability and the actor is not consulted on it ([live-view.md](live-view.md)). `ChannelDirectory { info(channelId) → { visibility }; isMember(actorId, channelId) → boolean | "unknown"; channelsOf(actorId) → Set | "unknown" }` is the adapter seam that supplies channel facts: it is the SOURCE of the `channelVisibility` stamp (dispatch asks it once per run) and of the actor's `memberOf` (the resolver asks `channelsOf` once per actor, for the linked person, waiting at most `CHANNEL_DIRECTORY_TIMEOUT_MS` like the stamp — past it the actor resolves without `memberOf` and the late answer fills the cache for the next request) — it is **not consulted at `authorize` time**, which reads only the stamped resource and the resolved actor; `unknown` — a channel the directory has never seen, a person it cannot list, an API failure, a timeout — is **never public and never a member**. Visibility alone gives, through item 4's `member-of`: `public` → every actor of the workspace is a member; `private` and `dm` → the run's own `userId` (`is-self`) and holders of `all-channels`; `machine` → the tokens granted that channel (`grants.channels`). **Membership** adds the third way: a dashboard session linked to its Slack person ([record 0042](../../decisions/0042-a-dashboard-session-is-the-person-its-email-names-identity-not-authority.md)) carries `memberOf` = the Slack directory's `channelsOf(person)` — every public and private channel the person is in **that the bot is also in** (`users.conversations` for the person answers within the bot token's own reach; a private channel the bot was never invited to is invisible to it and never had a run) — so a private channel's runs, its config scope and its MCP tier open to the people in it, on the runs index, the run page, the settings tabs and `/api` alike, with no handler change. Freshness is **event-driven first, TTL as the degraded path**: the bot subscribes to `member_joined_channel` / `member_left_channel` (forget that person's set — or everyone's when the member is the bot itself, since a channel the bot just joined is now visible on every member's set) and to `channel_left`, `group_left`, `channel_archive`, `group_archive`, `channel_deleted`, `group_deleted` (the bot's own reach moved: forget everyone's), and forgets everyone on every socket `connected` (Socket Mode replays nothing missed while down) — all over the existing Socket Mode connection, no inbound webhook, no new scope (`channels:read`/`groups:read` cover the events; the manifest lists them); the per-person cache (`MEMBERSHIP_TTL_MS`, ten minutes, bounded like the visibility cache, concurrent first asks sharing one paged call, a failure remembered for the window with one `[authz]` line) is what bounds a stale answer when an event never arrives. A stale-ALLOW after a member leaves therefore lasts until the leave event (milliseconds) or at most the TTL; a stale-DENY is impossible because `unknown` denies, and a new member's first read waits only for the join event or the TTL. A chat actor (`resolveChatActor`) does not carry `memberOf` yet: a Slack user's `runs list` still lists the public channels, the granted ones and their own ([gap] below). Membership is a third way in, so it changes nothing for an actor the first way already admits everywhere: in an installation whose `access:*` surface entry holds `channels: all`, every browser session reads every channel by grant and none exercises the membership half — the relation is proven there by its unit rows alone, and observed live only once a browser session without `all` exists. Every run is **stamped with `channelVisibility` at dispatch** (`dispatch()` asks `CoreDeps.channelDirectory` once per run and writes the answer into the `RunMeta` and the record), so neither read-side membership nor the memory write gate needs a Slack call at read time; a record without the stamp is `unknown` → private for writes, membership-gated for reads. Two directories implement the seam. The **static** one (`src/core/authz/channelDirectory.ts`, `visibilityOf` — the dispatcher's default, what the CLI and the tests get) is what the platform-namespaced id alone establishes: `http:*` / `mcp:*` → `machine`, `slack:D…` → `dm`, `slack:G…` → `private`, a Slack `C…` channel → `unknown`, because only `conversations.info` can tell a public `C…` from a private one. The **Slack** one (`src/channels/slackChannelDirectory.ts`, `SlackChannelDirectory`, wired by the bot in `src/index.ts` as soon as the Slack adapter is up) asks `conversations.info` for a `slack:C…` or `slack:G…` id — `is_im` or `is_mpim` → `dm` (a group DM is private to its members like a DM, and its facts belong to the person, item 8), `is_private` → `private`, else `public` — once per channel per TTL (`CHANNEL_INFO_TTL_MS`, ten minutes; a bounded FIFO cache of 1000 channels like the adapter's name caches; concurrent first lookups share one call), and answers **`unknown` on any failure** — an API error, a missing scope, a reply without a channel — remembering the failure for the same TTL so a failing channel costs one Slack call and one `[authz]` log line per window, never a call per message (a failure is grants-only visibility, never public). A `slack:D…` id is `dm` from the id alone and never reaches the API (no `im:read` needed); a non-Slack id is the static answer. The lookup needs the bot's `channels:read` (public `C…`) and `groups:read` (private `C…`/`G…`) — both already in the required set the startup scope check enforces ([slack-channel.md](slack-channel.md) item 6a). The dispatcher **bounds the wait**: a directory slower than `CHANNEL_DIRECTORY_TIMEOUT_MS` (1.5 s — a cold `conversations.info` is one round trip; every later message in the channel is a cache hit) stamps `unknown` and the run proceeds, so a Slack outage costs a reply at most that long. Consequence: a run in a **public** Slack channel is stamped `public` and readable by every actor (`member-of`'s public half) — a plain Slack user's `friction report` now counts the public channels' runs, their own, and the channels their grants name; a run in a private channel or a DM stays readable through channel grants, `all-channels`, or `is-self` only; admins and operators (`all`) are unaffected. A machine token's pinned channel is the same relation: `channels: {"http:ops"}` sees `http:ops` (plus any run stamped `public`) and nothing else; a token with NO channel grant sees only public runs and its own. 8. **Memory — reflection writes are gated.** For every candidate fact (and the summary), reflection resolves the extractor's `audience` to one of the run's scopes exactly as before ([memory.md](memory.md) item 23) and then calls `authorize(runActor, "memory:write", memory-scope { key, kind, originChannelVisibility })`, where `originChannelVisibility` is the run's stamped `channelVisibility` (item 7) and `runActor` is the run's principal (the same actor the chat commands resolve for the message's user id) **holding the run's own channel and repo as memberships** (`reflectionActor`): both are facts the dispatcher established before the run — the adapter delivered the message from that channel, the repo gate admitted that repo — stated on the membership axis so the `member-of` / `owner-of` rows recognize the run's own scopes; no action is added and `"all"` is left alone, so the one question left to the table is whether the origin may write `org`. That question: the `org` row carries the selector `originVisibility: [public, machine]`, so a fact whose run originated in a `private` or `dm` channel — or whose run is **unstamped / `unknown`** (a directory failure, a timeout, a record from before the stamp) — has **no `org` row** and is **narrowed**, never widened, never silently dropped: a `dm` origin narrows `org` → `user` (a DM is one person's conversation with the bot; its facts are that person's and travel with them), a `private` or `unknown` origin narrows `org` → `channel` (the origin's own audience — exactly its participants), and either falls to the other when the run lacks the first; `repo` is never a narrowing target, because a repo scope is read from every channel the repo is used in — wider than the origin. A narrowed correction is written **without its `supersedes`**: the record it would have retired lives in the scope the origin may not write, so that record stands. `user`, `channel`, and `repo` facts stay where the extractor put them (the audience is a hint the policy narrows, never widens); a fact from a `public` or `machine` origin keeps today's routing, org included. Any write the table denies for another reason — a candidate whose scope the actor is not a member of — is dropped, never rerouted wider. Every narrowing or drop is one `[memory]` line per reflection carrying **counts and reason tokens only** (`2× org → user (origin-visibility)`), never a fact's text. Reads are unchanged ([memory.md](memory.md) item 22): `memoryContextBlock` derives its scopes from the request and never calls `authorize`. -9. **Configuration — one `grants` shape and a `restrict` block.** `grants: { : { actions?: string[] | "all", channels?: string[] | "all", repos?: string[] | "all" } }` keyed by platform-namespaced actor id — three axes, no more (agents are actions, item 2); an absent axis is the empty set, `"all"` is explicit and never a default. `restrict: { agents?: string[], repos?: string[] }` names what is **closed unless granted**: a listed agent runs only for an actor whose grants hold `agent:run:` (or `all`), a listed repo is used only by an actor whose `repos` axis names it (case-insensitively, or `all`); everything unlisted is open to everyone who can reach the bot, and restricting one thing never takes a grant from anyone. `restrict.agents` must name registered agents and `restrict.repos` `owner/name` slugs, or the load fails naming the offender. `grantsFor(id)` is a namespace **baseline** plus the id's entry: a `slack:` id holds the open chat commands (`CHAT_OPEN_ACTIONS`) and `agent:run:` for every unrestricted agent, listed or not, and an entry ADDS to that ("exactly what it declares" is the rule for credentials); an `access:` browser session holds every registered group's read, an entry adds; `access:svc:`, `http:`, `mcp:` credentials hold exactly their entry, unlisted `NO_GRANTS`; a `schedule:` id holds what the schedule registry declares for it (`actor: { kind: "schedule", id: "schedule:", grants }`, no config list hand-names `http:cron`) unless the block names it, then config wins whole; `cli:local` everything. A key `:*` — `slack:*`, `http:*`, `mcp:*`, `access:*` — is a **surface entry**; `mcp:personal:*` is the narrower family for approved personal credentials: the same three axes, held by every actor that authenticated on that surface (`access:*` is every browser session, the org Cloudflare Access admits — never an `access:svc:` token). An actor's grants are the **union** of its own entry (or its baseline), and its surface entry: a personal entry never narrows the surface entry, so a person listed for extra rights keeps what everyone holds. `*` is a whole surface or the browser-approved MCP family `mcp:personal:*`: a partial subject (`slack:U*`) and `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` fail the load naming the key — schedules and service tokens are individually named identities, an agent derives from its principal, the CLI holds everything. A surface entry is never an actor, and an id no surface owns inherits no `*` entry (fail-closed, item 10). `config:write`, `repo:write`, and every `runs:*` are never a baseline. `grants` and `restrict` are the only authorization keys: any other top-level key (`permissions` among them) is unknown and fails the load by name, and an ingress token entry is exactly `{ subject, channel?, email? }` — `email` binds the credential to a person (item 15), and any other field (`scopes`, say) is ignored, so nothing in the token map can widen the grants entry. `canManageRepos` / `canEditChannelConfig` / `canRunAgent` / `canUseRepo` / `restrictedAgentsFor` all answer from the grants table. Shared permission guidance (`adminsHint`) always says "an admin", never enumerates people or emits mentions, regardless of the grants table. This applies to config summaries, command errors, agent and repo refusals, onboarding guidance and Ship preflight. The baselines: +9. **Configuration — one `grants` shape and a `restrict` block.** `grants: { : { actions?: string[] | "all", channels?: string[] | "all", repos?: string[] | "all" } }` keyed by platform-namespaced actor id — three configured axes (agents are actions, item 2); an absent axis is the empty set, `"all"` is explicit and never a default. `restrict: { agents?: string[], repos?: string[] }` names what is **closed unless granted**: a listed agent runs only for an actor whose grants hold `agent:run:` (or `all`), a listed repo is used only by an actor whose `repos` axis names it (case-insensitively, or `all`); unlisted agents and code repos are open to human namespace actors; credentials and jobs always require their own grants, and restricting one thing never takes a grant from anyone. `restrict.agents` must name registered agents and `restrict.repos` `owner/name` slugs, or the load fails naming the offender. `grantsFor(id)` is a namespace **baseline** plus the id's entry: a `slack:` id holds the open chat commands (`CHAT_OPEN_ACTIONS`) and `agent:run:` for every unrestricted agent, listed or not, and an entry ADDS to that ("exactly what it declares" is the rule for credentials); an `access:` browser session holds every registered group's read, personal writes and unrestricted agent actions, an entry adds; `access:svc:`, `http:`, `mcp:` credentials hold exactly their entry, unlisted `NO_GRANTS`; a `schedule:` id holds what the schedule registry declares for it (`actor: { kind: "schedule", id: "schedule:", grants }`, no config list hand-names `http:cron`) unless the block names it, then config wins whole; `cli:local` everything. A key `:*` — `slack:*`, `http:*`, `mcp:*`, `access:*` — is a **surface entry**; `mcp:personal:*` is the narrower family for approved personal credentials: the same three axes, held by every actor that authenticated on that surface (`access:*` is every browser session, the org Cloudflare Access admits — never an `access:svc:` token). An actor's grants are the **union** of its own entry (or its baseline), and its surface entry: a personal entry never narrows the surface entry, so a person listed for extra rights keeps what everyone holds. `*` is a whole surface or the browser-approved MCP family `mcp:personal:*`: a partial subject (`slack:U*`) and `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` fail the load naming the key — schedules and service tokens are individually named identities, an agent derives from its principal, the CLI holds everything. A surface entry is never an actor, and an id no surface owns inherits no `*` entry (fail-closed, item 10). `config:write`, `repo:write`, and every `runs:*` are never a baseline. `grants` and `restrict` are the only authorization keys: any other top-level key (`permissions` among them) is unknown and fails the load by name, and an ingress token entry is exactly `{ subject, channel?, email? }` — `email` binds the credential to a person (item 15), and any other field (`scopes`, say) is ignored, so nothing in the token map can widen the grants entry. `canRunAgent` and `canUseRepo` call `authorize` on the resolved actor. Human code access is compiled separately from the explicit `repos` ownership axis used by repo memory; neither a credential bound to a person nor a delegation chain gains the person's open defaults. The other config gates read the same effective grants. Shared permission guidance (`adminsHint`) always says "an admin", never enumerates people or emits mentions, regardless of the grants table. This applies to config summaries, command errors, agent and repo refusals, onboarding guidance and Ship preflight. The baselines: | Actor id | Baseline (held listed or not) | A `grants` entry … | |---|---|---| | `slack:U…` | `{ actions: CHAT_OPEN_ACTIONS + agent:run: for every agent not under restrict.agents }` — `help:read, status:read, config:read, repo:read, friction:read, memory:read, mcp:read, schedule:read, memory:write, mcp:write, steer:write` | adds to it | - | `access:` | `{ actions: every :read + memory:write, mcp:write, steer:write }` (the groups the store is handed at startup; the personal chat writes, including owner-checked steering, since the web chat, [record 0043](../../decisions/0043-the-home-page-is-a-chat-the-browser-is-a-channel-and-a-turn-is-a-run.md) — the tier rows still decide the target) | adds to it | + | `access:` | `{ actions: every :read + memory:write, mcp:write, steer:write + unrestricted agent actions }` (the groups the store is handed at startup; the personal chat writes, including owner-checked steering, since the web chat, [record 0043](../../decisions/0043-the-home-page-is-a-chat-the-browser-is-a-channel-and-a-turn-is-a-run.md) — the tier rows still decide the target) | adds to it | | `access:svc:`, `http:`, `mcp:` | nothing (`NO_GRANTS`) | is exactly what it holds — `dispatch` included | | `schedule:` | the registry's declared grants (`self-improvement`: `{ actions: {friction:read, friction:write}, channels: all }`) | replaces them whole | | `cli:local` | `{ actions: "all", channels: "all", repos: "all" }` — the local operator | — | @@ -97,8 +97,8 @@ One decision, `authorize(actor, action, resource) → allow | deny(reason)`, ove | With the Slack directory behind the stamp, a run in a public channel is stamped `public` and matches a plain Slack user's `runs:read` predicate; a private-channel run and a DM run do not; `conversations.info` is asked once per channel across runs and never for a DM | `[unit]` `src/core/dispatcher.test.ts::run history write path …::Slack directory stamp: a public-channel run is visible to a plain Slack user's run reads, a private-channel or DM run is not; conversations.info is asked once per channel across runs` | | The dispatcher bounds the directory wait: a directory that never answers stamps `unknown` after `channelDirectoryTimeoutMs` (default `CHANNEL_DIRECTORY_TIMEOUT_MS` = 1.5 s), logs `[authz] channel directory timed out`, and the reply still lands | `[unit]` `src/core/dispatcher.test.ts::run history write path …::a slow channel directory cannot hold a reply: past \`channelDirectoryTimeoutMs\` the run is stamped \`unknown\` and proceeds` | | `grants` parses fail-closed: an absent axis is the empty set, `all` explicit; an unknown id prefix, a misspelled `all`, an unknown field (the removed `agents` axis included), a non-mapping block, or an empty name fails the load naming the id and field — never widens; a whole-surface key (`slack:*`, `http:*`, `mcp:*`, `access:*`) parses like any entry, and `*` anywhere else — a partial subject, `schedule:*`, `access:svc:*`, `agent:*`, `cli:*` — fails the load naming the key | `[unit]` `src/core/authz/grants.test.ts::parseGrantsConfig — the native \`grants\` block::*`, `src/config.test.ts::grants config — the one shape …::*` | -| `restrict` parses fail-closed: absent → nothing restricted; an unregistered agent, a non-slug repo, an unknown field, or a non-list fails the load naming the field; repos compare case-insensitively; an unrestricted agent or repo is open to anyone, a restricted one only to a holder of `agent:run:` / a `repos` grant (or `all`); `restrictedAgentsFor` names what an actor may not run; an unknown top-level key (`permissions` is one) fails the load naming it, nothing mapped or ignored | `[unit]` `src/core/authz/grants.test.ts::parseRestrictConfig — what is closed unless granted::*`, `::mayRunAgent / mayUseRepo — open unless restricted, then only for a holder::*`, `src/config.test.ts::restrict — closed unless granted (authorization.md item 11)::*`, `::permission gates::*`, `::per-repo access (canUseRepo)::*` | -| The baselines and the lookup: `slack:` → the open commands + every unrestricted agent (never `config:write`, never `runs:read`), `access:` → every group's read plus `memory:write`, `mcp:write` and `steer:write`, credentials and unlisted schedules nothing; a `slack:` or browser entry ADDS to its baseline, a credential holds exactly its entry, `all` absorbs the baseline, a schedule's declared grants are replaced by a native entry; `canManageRepos` / `canEditChannelConfig` answer from the table; an ingress token entry is exactly `{ subject, channel?, email? }`, any other field ignored; a surface entry (`:*`) is unioned into every actor of its surface on top of its own entry and baseline — alone, beside a wider personal entry, beside a narrower one (the union wins) — `access:*` never reaches an `access:svc:` token, an id no surface owns stays `NO_GRANTS`, a restricted agent opens through a surface entry's `agent:run:`, and `adminsHint` always says "an admin" without naming people or surfaces | `[unit]` `src/core/authz/grants.test.ts::the baselines — what an id holds by its namespace, listed or not …::*`, `::grantsTable / grantsIn / grantsFor — the lookup::*`, `::surface entries — what every actor authenticated on a surface holds::*`, `src/core/authz/actor.test.ts::resolveActor…::*`, `src/config.test.ts::grantsFor — the grants the policy table decides on::*`, `src/core/ingressTokens.test.ts::parseIngressTokenMap::an identity is exactly { subject, channel?, email? }…`, `src/channels/http.test.ts::parseIngressTokens — a token is a credential, its rights are config's::*` | +| `restrict` parses fail-closed: absent → nothing restricted; an unregistered agent, a non-slug repo, an unknown field, or a non-list fails the load naming the field; repos compare case-insensitively; an unrestricted agent or code repo is open through the human baseline, a restricted one only to a holder of `agent:run:` / a `repos` grant (or `all`); `restrictedAgentsFor` names what an actor may not run; an unknown top-level key (`permissions` is one) fails the load naming it, nothing mapped or ignored | `[unit]` `src/core/authz/grants.test.ts::parseRestrictConfig — what is closed unless granted::*`, `src/config.test.ts::restrict — closed unless granted (authorization.md item 11)::*`, `::permission gates::*`, `::per-repo access (canUseRepo)::*` | +| The baselines and the lookup: `slack:` → the open commands + every unrestricted agent (never `config:write`, never `runs:read`), `access:` → every group's read plus `memory:write`, `mcp:write`, `steer:write` and unrestricted agent actions, credentials and unlisted schedules nothing; a `slack:` or browser entry ADDS to its baseline, a credential holds exactly its entry, `all` absorbs the baseline, a schedule's declared grants are replaced by a native entry; `canManageRepos` / `canEditChannelConfig` answer from the table; an ingress token entry is exactly `{ subject, channel?, email? }`, any other field ignored; a surface entry (`:*`) is unioned into every actor of its surface on top of its own entry and baseline — alone, beside a wider personal entry, beside a narrower one (the union wins) — `access:*` never reaches an `access:svc:` token, an id no surface owns stays `NO_GRANTS`, a restricted agent opens through a surface entry's `agent:run:`, and `adminsHint` always says "an admin" without naming people or surfaces | `[unit]` `src/core/authz/grants.test.ts::the baselines — what an id holds by its namespace, listed or not …::*`, `::grantsTable / grantsIn / grantsFor — the lookup::*`, `::surface entries — what every actor authenticated on a surface holds::*`, `src/core/authz/actor.test.ts::resolveActor…::*`, `src/config.test.ts::grantsFor — the grants the policy table decides on::*`, `src/core/ingressTokens.test.ts::parseIngressTokenMap::an identity is exactly { subject, channel?, email? }…`, `src/channels/http.test.ts::parseIngressTokens — a token is a credential, its rights are config's::*` | | Schedule actors are declared in the registry with their grants; a firing is authorized by the table (no `http:cron` in any permissions list); a schedule whose actor has no token sends nothing and records `misconfigured` | `[unit]` `src/core/schedules.test.ts::schedule registry::every run schedule declares its … actor as schedule: WITH its grants…`, `::planScheduledFiring…::fail-closed: no token map, an unparseable map, or no … identity → nothing to run, with the reason` | | Item 4, the channelConfig read half: `config show --channel` and the instructions peek admit a public channel's scope from anywhere, a private, DM or `unknown` one only from inside it (no lookup) or by grant (the admin, a `config:write` holder, a credential's channel grants), a credential on `config:read` alone reads only a public one, and every refusal is the one `unauthorized` line that never carries the instructions; both `config:read` rows on `config-scope { channel }` have allow and deny cases, the pointing actor's included | `[unit]` `src/core/commands/config.test.ts::config show --channel is bound by the target channel's visibility::*`, `src/core/authz/policy.test.ts::POLICY coverage::*::allows its allow cases through this row and through the table`, `::*::denies its deny cases in this row and across the table`, `src/core/authz/resource.test.ts::attributesOf::repo, config-scope, agent, command` | | Every admission is a policy row, decided the same on every surface: the Slack baseline (the open commands), `repo:write`, `config:write` for the channel scope (never a baseline, asked by the handler on `config-scope { channel }` with its own reply text), `agent:run` (`repo test|build` resolve `agent { coding }`); `defineCommand` declares `action` and nothing else about admission; every command in `commands/all.ts` has a row on the resource it authorizes and a command without one fails the suite by name and is refused for everyone | `[unit]` `src/core/commandConformance.test.ts::command conformance — catalogue fences::authorization: every command's action has a policy row…`, `::authorization: a command whose action has no policy row fails loudly, by name`, `src/core/commands/config.test.ts::config set::\`channel\` targets the caller's channel (or --channel) and rides config:write…`, `src/core/commands/repo.test.ts::repo test / repo build…::the table decides on \`agent { coding }\`…`, `src/core/commands/mcp.test.ts::mcp.* commands::channel and org scopes are decided by the data…`, `src/core/commands/memory.test.ts::memory.forget::forgetting a SHARED record…` | @@ -119,6 +119,13 @@ One decision, `authorize(actor, action, resource) → allow | deny(reason)`, ove | Item 17: the routes — `POST /runs/view-as` sets the cookie for a session holding `all` (204, HttpOnly, SameSite=Strict, Path=/, Secure on https) and refuses anyone else before the body is read; a body naming no Slack person is 400, a foreign origin 403, GET 405; `POST /runs/view-as/exit` clears it; the parser reads the cookie back by name and nothing else | `[unit]` `src/channels/viewAs.test.ts::viewAsFromCookie::*`, `src/channels/viewAs.test.ts::the view-as routes::*`, `src/channels/viewAs.test.ts::refuseWhileViewing::*` | | Item 17, human-gated: on the deployed installation the maintainer picks a person who is in a private channel on `/runs`, sees that channel's runs and no DM of their own, sees the banner on every page, is refused a Remove on `/settings/mcps` and a Stop with the sentence, and Exit returns their own page | `[agent]` receipt on the PR and on the authorization receipts ledger | +## Credential scope proof + +| Criterion | Evidence | +|-----------|----------| +| Resolved service credentials run only granted agents and use only granted code repos, even when globally unrestricted; bound people, relays and nested delegation never widen that authority. Human open defaults preserve explicit memory ownership. | `[unit]` `src/core/authz/scopedCredentialPolicy.test.ts::compiled credential authority::*` | +| Code repo access compiles to the same point, predicate and serialized filter decisions, including case normalization and missing repos; complements are bounded and unknown filter kinds fail closed. | `[unit]` `src/core/authz/scopedCredentialPolicy.test.ts::compiled credential authority::*`, `deploy/cloudflare-memory/runs.test.ts::repo complement filters match point and wire semantics in SQLite and reject malformed complements` | + ## Roadmap (gaps) | Criterion | Evidence | diff --git a/docs/reference/specs/resident-repos.md b/docs/reference/specs/resident-repos.md index 175b39297..597702086 100644 --- a/docs/reference/specs/resident-repos.md +++ b/docs/reference/specs/resident-repos.md @@ -39,7 +39,7 @@ Base URL for all `[agent]` checks: the resident Worker's own hostname, written ` 24. **Bot-side selection is warm-gated and probe-per-dispatch**: `makeExecutor(opts, {threadKey, agent, profile, repo?, ref?})` returns `{executor, note?}`. Resident selection requires a run whose effective profile is on the `repo-resident` machine class ([execution.md](execution.md) item 7), a configured `execution.resident {baseUrl, tokenEnv?, probeTimeoutMs?}` (operator bearer read from `tokenEnv`, default `RESIDENT_OPERATOR_TOKEN`), AND a resolved `ctx.repo` — populated by the repo resolver (item 29); undefined means the per-thread path with NO probe (total input contract). Membership is never cached: each dispatch makes one operator `GET /status` probe. A probe the Worker answered with a 5xx it typed transient (the catch-all 500: the Durable Object reset or lost under the probe) is waited through — re-probed at once and then under the first attach's budget, the run's stop riding in, the wait named on the card — for a fresh run's selection, a resumed run's re-attach and a mid-run relaunch's alike, and never opens the transport breaker below ([execution.md](execution.md) item 9). A **serviceable** state → `ResidentExecutor` (`isServiceable` in `src/execution/residentState.ts`, the lifecycle union shared with the resident Worker so a renamed state is a compile error on both sides): `warm` with a POSITIVE `note` = `resident · · @` taken from the attach answer (the resident path is named on the card, never inferable only from the absence of a fallback note); every OTHER `note` — a failed attach, an unreachable or unserviceable resident, a repo not onboarded — is also published to the run's stream as a **`cold_sandbox` run note** with the same text, after the attach and before the first turn, head material like the rest of the setup ([tracing.md](tracing.md) item 16), so the run page explains a sandbox run whose attach span carries the resident's grafted steps — the card is not the only witness; `refreshing` (the resident keeps serving the last snapshot — item 7 — and `/attach` refuses nothing; the mirror lock serializes the attach against a refresh's fetch and its stage/swap (item 47 — the build runs off-lock, so the wait is git-network-sized, never build-sized), bounded by `ATTACH_MUTEX_WAIT_MS` → a `mirror-busy` 503 falls back like any attach failure; so does a `disk-pressure` 503 — item 55 — whose `error` carries the budget math onto the card); and `degraded` **only for the one reason class that proves the checkout intact** — `github-unreachable: …` (fetch failed before the checkout was touched); not `disk-full: …` (item 54 — the checkout may be intact, but no attach can succeed on a full disk, and recording that failure as `github-unreachable` sent every run to attach and die at git-setup); not `stale-mid-flight: …`, whose orphaned cycle may have died inside the rebuild (the next refresh instance rebuilds within one bucket anyway) — because an orphaned cycle's state is unknown from outside: the reset/clean, install and build run in the staging tree and a failure there leaves the warm checkout untouched at its old sha (item 47), but a cycle that died inside the swap's lock section (`checkout-swap-failed` between its two renames, or `snapshot-failed` / `refresh-failed` after them with the markers half-written) can leave the checkout torn, and a fresh thread whose lockfile key matches would hardlink that cache; those, and unknown reasons, stay cold until the next cycle rebuilds. A non-warm attach carries an informational `note` = `resident () · @ — attached to the last snapshot` on the card. Gating on `warm` alone (the original rule) sends every run cold for the whole of every refresh window — with an active default branch (each merge a rebuild on the 10-min cycle) plus each resident deploy's restore, that is most of a working day of `resident refreshing — using fresh sandbox`. 404/not-onboarded → the ordinary per-thread path (AE4 — no resident exists, nothing degraded) but with a visible `note` = ``repo not onboarded as a resident — running in a cold per-thread sandbox; onboard it (`repo onboard `) for a warm, deps-ready environment`` so the cold fall-through is never a silent surprise; the engine-owned states `onboarding` (nothing to attach), `restoring` (disk being rehydrated), `down` (only a rebuild escapes) → per-thread fallback with `note` = `resident () — using fresh sandbox`. Every fallback note rides on the dispatcher's status-frame title (a degraded or cold-fallback resident is loud, never a silent stall). A resumed run's re-attach never falls back (`ExecutorContext.reattach`, [run-history.md](run-history.md) item 54): the backend its row recorded is the only one consulted, in reuse-only mode (item 66), and a refusal, an unreachable or unserviceable resident, or an answer naming another worktree or pool user than the row's is a `WorkspaceReattachRefusedError` the dispatcher restarts the run on; the `cold_sandbox` note is a fresh run's alone. 25. **Probe circuit breaker**: probe TRANSPORT failures (a fetch error, or the probe's own deadline — `probeTimeoutMs`, default `PROBE_TIMEOUT_MS` 8 s — passing) fall back as `resident unreachable (…)`, and of those only a failure to REACH the host arms the 30s in-process negative cache, so a resident-service outage costs one failed connect, not one per concurrent dispatch. A deadline miss arms nothing: the host was reached and answered slowly (`/status` fans out to the registry and four resident reads on a Durable Object that may be busy), which is that dispatch's cold fallback and nobody else's — the probe marks it `timedOut` beside `transport`, and the same rule holds for the `/residents` listing. The deadline was 2 s and a miss armed the window; one miss in 1,633 probes on a healthy resident then cost its run a cold, unseeded sandbox and every dispatch of the next 30 s the same, so the deadline is wide enough for the route's fan-out and a miss is no longer read as an outage. A resident that is gone fails the connect long before the deadline, so the wide deadline costs a dead resident's dispatch nothing extra; a wedged one costs its dispatch the deadline once; the run's own stop aborting a probe — the first probe or a re-probe of the blip wait of [execution.md](execution.md) item 9 — is the stop, never the outage, and arms nothing; the edge's own page — a 5xx with no Worker document — that the selection's blip wait was spent on — more of the wait's views document-less than Worker-typed, never its last view alone — arms the window at the wait's end (the platform not answering, paid once), while a wait spent on the Worker's typed blip never does. Definite lifecycle answers — including `degraded`/`down` — are NEVER cached (the next dispatch must see a recovery immediately). In-process only; the restart-survival invariant is untouched. -26. **Per-repo gate**: `restrict.repos` (with the invoking user's `repos` grant) is checked by the dispatcher BEFORE executor selection. An unlisted repo = open to every allowed coding-agent user; a listed repo refuses ungranted users with a named 🚫 reply naming the repo (admins always pass) — a refusal is a reply the user sees, never a silent per-thread fallback, and no executor is created. +26. **Per-repo gate**: `restrict.repos` (with the invoking user's `repos` grant) is checked by the dispatcher BEFORE executor selection. An unlisted repo is open to human namespace actors; credentials and schedules use only explicitly granted repos; a listed repo refuses ungranted users with a named 🚫 reply naming the repo (admins always pass) — a refusal is a reply the user sees, never a silent per-thread fallback, and no executor is created. 27. **ResidentExecutor client** (attach-on-open): `open()` attaches before the first tool call so needs-ref / not-onboarded / mirror-busy surface legibly at selection time — the 409 `needs:"ref"` answer throws a TYPED `ResidentNeedsRefError` the dispatcher catches for the ask-once flow (item 30). Every route POSTs `{resource, threadKey, ...}` with the operator bearer and no x-env headers; `/exec` responses are read as full text (heartbeat whitespace + one JSON document) and parsed from the body, never the HTTP status; a non-zero `exitCode` is a result (`exit N: …`), not an error; an in-body `{error}` without `needs` is thrown verbatim and never blind-retried. A `needs:"attach"` error on any op for an evicted worktree (`evicted:`, `not-attached:` — the container stands, the binding survives) triggers exactly one re-attach + retry of the same call; a second `needs:"attach"` is a legible error. The preflight's `worktree-missing:` (the container disk was recycled since the last attach) says the container under the thread is gone with every process the run had in it: on `/exec` it is the typed `ExecSandboxRestartedError` at once, carrying the resident's words, with no re-attach and no retry (`saysContainerGone`, `containerGoneMessage` in `src/execution/residentWake.ts`) — the pi harness reads it as the container replaced under the run ([harness-pi.md](harness-pi.md) item 16) — and on the idempotent routes the one re-attach + retry as for any `needs:"attach"`. A `reason:"runtime-replaced"` answer is handled per item 43 (`/exec`: the same typed restart at once; `/read`, `/write`: re-attach once and retry). 28. **Plumbing**: `RESIDENT_OPERATOR_TOKEN` reaches the bot container through the Worker shim's explicit envVars allowlist (`deploy/cloudflare/worker.ts` + `deploy/secrets.manifest.json`, the same path as `SANDBOX_TOKEN`); the production config (`config/config.production.yaml`) points `execution.resident` at the resident Worker's URL. `src/cli.ts` accepts `--thread ` / `--thread=` or `SWITCHBOARD_THREAD` (default stays ephemeral `cli:`) so repeated CLI invocations act as ONE thread — the vehicle for re-attach/binding-persistence verification. 29. **Repo/ref resolved BEFORE the model turn**: `src/core/repoContext.ts` is the production default behind the dispatcher's `CoreDeps.resolveRepoContext` seam (still injectable for tests), run only for an [agent](../vocabulary.md#agent) whose machine class carries a checkout and with the vet that class names ([execution.md](execution.md) item 18): the registry probe and listing below for `repo-resident`, GitHub for `repo-cold`; `none` and `blank` never resolve a repository. An accepted non-review operation target supplies its repository and any accepted ref before message or history extraction, without a discovery probe; a contextual PR cannot replace either. With a repository-only target, an explicitly requested same-repository PR URL, shorthand or bare number may supply its verified open head; conflicting requested PRs refuse before a fetch, while contextual citations remain evidence. Otherwise, extraction sources in priority order: explicit signals in the CURRENT message — an `owner/name` slug token, a github.com repo URL (Slack markup `` unwrapped; `/tree/` yields the ref too), a PR URL or `owner/name#N` shorthand — then the thread's previously-established repo derived from history like `lastThreadDirectives` (user turns, last wins; restart-safe, never stored), then none → `{}` → the per-thread path with no probe (AE4). **The thread's own pull request binds the follow-up**: when the current message names no PR, the thread's PR is the newer of the last one a person named in a user turn and the one the thread's newest finished run opened or edited (`RunRecord.pr`, [run-history.md](run-history.md) item 2, handed in from the dispatcher's one read of the thread's runs, [routing-and-config.md](routing-and-config.md) item 3) — the turn's timestamp against the run's end, and the run's when either is undated, since it is the thread's own work. The record's PR binds the ref to its head branch when the message phrases none (`refFromPr`, with `prFromRecord` saying the PR is the thread's own), and the resident honours it: the attach carries the PR as `ownPr` (`ownPrOf`) and a thread bound to the repo default moves onto that branch, once per pull request — the tree provisioned clean at the branch the run's release told the resident it pushed (item 16, item 16a) — so a coding follow-up pushes to the PR and its resubmitted description edits it instead of being refused on the repo default — **from whatever ref the follow-up ran on**: when the run pushed nothing and its workspace sits on the base (the binding still the repo default, the move refused or the tree recreated there), the post-step edits the thread's PR by number, the body rendered at the PR's own head, rather than refusing "the branch is the base" (`recordPrOf`, `CodingPrTarget.ownPr`; [pr-description.md](pr-description.md) item 5). The card reads `resident · · @ · rebound to (this thread's PR #N)`, or names the refusal that kept the thread where it was (`· rebind to (this thread's PR #N) refused: `), which the run's stream repeats as a `rebind_refused` note — or, once that pull request has merged and its branch is gone, `· returned to (the branch of this thread's PR #N is gone)` (item 16's second movement). A person's PR keeps the rule below (inherited fail-closed, the ref never rebound, `ownPr` never sent), and a ref in the message wins over both. A closed or unfetchable PR binds no ref and no `pr` (`prUnpostable` says why) — but the record's own PR, once merged or closed, is still handed to the coding post-step as `closedRecordPr` with the head commit GitHub names for it, so a description resubmitted in its thread edits its body ([pr-description.md](pr-description.md) item 5); a turn's closed PR is unpostable and nothing more; a record's PR of another repository is not the thread's. **Addressed repos** (without this, a thread bound to one repo by an issue link keeps every later `in acme/api` run on that repo's resident — a bare slug is weak and could not rebind — while an opening `in api` carries no signal at all and goes cold): the first token after the word `in` (outside code) names the request's TARGET — `in owner/name` anywhere in the message, or a bare `in name` in the DIRECTIVE position only (nothing but `agent:`/`model:` directives or a mention before it — `agent:coding in api, …`; "the crash is in api, see the logs" is prose even when a repo is called `api`). Vetted against the resident registry it is STRONG like a URL: it binds a fresh thread and rebinds a bound one, and "last strong wins" holds across URLs and addresses alike (the resolver walks the thread's user turns backwards, vetting each address). A bare NAME resolves only through the registry listing (`GET /residents`, the admin bearer — the route is read-scoped) and only when exactly one onboarded repo carries it; an unknown (`in production`) or ambiguous name is prose and binds nothing, a failed listing is no answer. The strength and code-span guards below stand: an address the probe refuses (`in try/catch`, `in docs/reference/specs/memory.md`) changes nothing unless the current message also names a bare PR number, in which case resolution stops rather than borrowing the thread’s older repo, a merely-mentioned onboarded slug ("also check acme/web") stays weak, and without a probe (local/dev) nothing can be vetted so an addressed slug stays the weak token it always was. One probe per candidate per resolution. **Silence is not a refusal**: the probe answers `"unreachable"` (transport failure, timeout, the outage window) apart from `false` (not onboarded), and when the current message addresses a slug the registry could not be asked about, the resolver returns `unverifiedRepo` — never a silent fall back to the thread's old repo — and the dispatcher replies "couldn't verify … the resident registry didn't answer" without starting a run (an unanswered address in history is simply no answer; a fresh thread whose only candidate went unanswered reports `unverifiedRepo` rather than "not onboarded"). The run card names the bound repo (`resident · · @`) so a wrong binding is readable from Slack, not only from a sha. Without an accepted operation target, an explicitly requested PR yields BOTH repo and head ref via ONE `GET /repos/{owner}/{repo}/pulls/{n}` REST call authenticated with `resolveGithubToken()` — NEVER a `gh` shell-out from the dispatcher (AGENTS.md invariant 5); unauthenticated works for public repos, any fetch failure degrades to repo-only, and cross-fork head refs are never bound (they don't resolve in the mirror). **With an accepted operation target, the fetch runs when the current message explicitly requests a PR of the accepted repo — regardless of ref phrasing beside it; a contextual citation does not trigger it. The legacy non-review path can still fetch a contextual PR extracted as `namedPr`** ([agent-review.md](agent-review.md) §11): a fetched open PR supplies the ref unless an accepted operation ref already fixed it; a prose `on main` / `on branch X` in the same PR-target message binds no ref, including when the fetch fails. **Only an OPEN pull request contributes the ref hint** (`refFromPr` is set only from an open one; a state the answer did not carry binds nothing): a merged or closed pull request cited as a receipt in a coding ask must not bind the thread's branch to its dead head branch — a ship child bound there has its contract branch refused by the resident's push guard and the commit orphans (issue 1860) — while its facts (`pr`, `headSha`, `prDescription`) still ride as context and a phrase's ref stands. And a coordinator child's ref hint is its contract's branch, always: a PR-derived ref the resolver bound off the child's own request text is dropped by the dispatcher before the attach — the drop `refFromPr`'s contract asks of a consumer that does not bind the PR — with the head sha that pinned the cited PR's head (a review child keeps the binding: the pull request is its target). For a non-review run, a message-cited PR that did not bind the ref (`prFromMessage` without `refFromPr`) contributes an attach SHA only when its verified open head matches the accepted operation ref; the dispatcher drops every other `headSha` before attach, so a merged PR's frozen head stays context — the card, the record — never item 51's expected commit (which the resident could only refuse `stale-tip`, sending the run cold at the dead commit). (Before, a bound ref skipped the fetch — a re-review saying "rebuilt on main" lost its head SHA and the review was refused.) The ref is a typed slot, never a prose guess: only the head-of-ask routing clause (`… in owner/name on branch X: task`), a `branch:X` token, a pull-request URL's fetched head or a `/tree/` URL fills it; every candidate is validated against the resident's strict ref pattern (no `..`/`@{`/`.lock`); slugs are validated owner/name shapes, lowercased (resident resource ids are lowercase) — invalid tokens are ignored whole, never partial garbage. **Code never establishes a repo**: a token inside an inline code span (`` `unset/unset` ``, `` `src/core` ``) or a fenced code block (pasted logs/diffs) is code or a path being talked about, not a repo switch, so ordinary target extraction ignores it, including URL/PR and typed branch forms; the coordinator's routed-PR conflict gate still checks quoted PR citations. Thus ``on `main` `` remains prose (`src/core/repoContext.test.ts::a token inside a code span never establishes a repo…`, `::a slug inside a fenced … block…`, `::backticked prose does not bind a ref…`, `::repoFromThread ignores code-spanned tokens in history too`). An `on ` token is only a legacy repository mention when no repository is independently established; it never becomes a ref. **Signals have two strengths, and the thread binding is sticky against weak ones**: a github.com URL (repo/PR/`/tree`) or `owner/name#N` is STRONG — unambiguously a repository; a bare `owner/name`-shaped token is WEAK — it is also the shape of every relative file path and of ordinary prose, and code spans only catch the backticked ones. A thread bound by a strong signal is rebound ONLY by another strong signal (in the current message or a later user turn); weak tokens are consulted only while nothing strong has bound the thread. Otherwise a prose token shaped like a path (e.g. `docs/reference/specs/memory.md`) would select a cold sandbox for a non-existent repo AND unbind the thread's PR. (`src/core/repoContext.test.ts::a bare slug-shaped token never rebinds a thread bound by URL…`, `::a bare slug in an EARLIER follow-up…`, `::redirecting the thread to another repo BY URL…`, `::repoFromThread: the last STRONG user-turn signal wins…`.) **Bare tokens are guarded two more ways.** (1) A bare token **never overrides a repo the thread already established, whatever its strength** — the first binding stays until a STRONG signal replaces it, so a thread opened with a bare `in owner/name` is not hijacked by later prose (`reflection/review-post`, `try/catch`, `comment/spec` are all slug-shaped). (2) In a thread with **no repo yet**, a bare token binds only when the resolver's injectable **resident probe** (`resolveRepoContext(msg, history, isResident?)` / `repoFromThread(history, isResident?)`) confirms it names an onboarded resource; the production probe (`residentOnboardedProbe` in `src/execution/factory.ts`) is one operator `GET /status` through the same negative cache as executor selection — any lifecycle state of an onboarded resource is a yes, `not-onboarded` and every unreachable/error answer is a no (fail-closed). No resident configured, or its bearer unset → no probe → bare tokens bind unvetted as before (local/dev). **A refusal is reported when it is the whole story**: when nothing binds and the first bare candidate the probe refused exists, the resolver answers `{ rejectedRepo }` and the dispatcher — for a repo-needing agent only — closes the ack card `not started (repo not onboarded)` and replies 📦 with the slug, `repo onboard `, and the github.com URL form as the way to run cold anyway; no attach, no model turn (before: an empty workspace and `fatal: not a git repository`). A thread that already has a repo carries `rejectedRepo` only when a refused current-message address and a bare PR number would otherwise select its older repo; otherwise the silence is unchanged. (`src/core/repoContext.test.ts::bare prose slugs never hijack a thread …::*`, `::a STRONG repo signal in the current message beats the thread's; a bare slug does not`, `::repoFromThread: a bare slug binds a thread that has no strong signal — first bind wins…`.) @@ -333,7 +333,7 @@ The resolver's literal result objects are the independent proof for current-mess | attach-on-open surfaces needs-ref (name a branch) and not-onboarded legibly | `[unit]` `src/execution/resident.test.ts::ResidentExecutor.open (attach-on-open)` | | a successful attach records `binding {ref, sha}`; needs-ref carries the Worker's `defaultRef` (undefined from an older Worker) | `[unit]` `src/execution/resident.test.ts::records the attach result's ref@sha as the thread binding …`, `::409 needs:"ref" carries the resident's defaultRef …` | | the warm path is named positively on the card (a `debug` note, [routing-and-config.md](routing-and-config.md) item 28) — `resident · · @`; bind-by-default re-attaches once on the resident's `defaultRef` and says so; a 409 without `defaultRef` still propagates `ResidentNeedsRefError` | `[unit]` `src/execution/factory.test.ts::warm probe → ResidentExecutor …` (note asserted), `::needs-ref WITH the resident's defaultRef → re-attach once on that ref …`, `::warm probe then a needs-ref attach failure → ResidentNeedsRefError propagates …` | -| per-repo gate (item 26) — open-when-absent; configured allowlist refuses BY NAME with no executor | `[unit]` `src/config.test.ts::per-repo access (canUseRepo)`; `src/core/dispatcher.test.ts::resident repo dispatch::a canUseRepo refusal is a named reply and no executor is created` | +| per-repo gate (item 26) — human open-when-absent, credentials explicitly scoped; configured allowlist refuses BY NAME with no executor | `[unit]` `src/config.test.ts::per-repo access (canUseRepo)`; `src/core/dispatcher.test.ts::resident repo dispatch::a canUseRepo refusal is a named reply and no executor is created` | | fallback note reaches the status card AND the run's stream as a `cold_sandbox` note — after the attach, before the loop, head material | `[unit]` `src/core/dispatcher.test.ts::resident repo dispatch::a resident fallback note appears in the status frames and on the run's stream as a cold_sandbox note (named, never silent)` | | CLI stable thread key (`ask --thread ` > ephemeral) | `[unit]` `src/cli.test.ts::parseCliArgv — the \`ask\` built-in…`; `[agent]` `npx tsx src/cli.ts ask --thread cli:demo "help"` answers inline (flag parsed, no crash). | | live executor round-trip against the deployed resident | `[agent]` tsx script driving `ResidentExecutor` at `repo:jshttp/vary`: `probeStatus` → `{state:"warm"}`; `open` attaches; exec `node -e` → `hi from resident` as `worker2` on `master`; write/read round-trip byte-identical; `ls /no/such/dir` → `exit 2:` result (not an error); `/debug backdate-thread + sweep-now` eviction then exec → transparent auto-re-attach (recreated worktree, clean `git status`); fresh-thread `open` without refHint → legible "name the branch" error. | diff --git a/docs/reference/specs/routing-and-config.md b/docs/reference/specs/routing-and-config.md index 2b75b59c4..22454285a 100644 --- a/docs/reference/specs/routing-and-config.md +++ b/docs/reference/specs/routing-and-config.md @@ -156,8 +156,8 @@ Installations using `extends: builtin` resolve profiles and all model callers th | The CLI starts the same open the bot does, but every config-backed dep reaches for it through an async accessor, so only a command that touches the config waits for it: with a state Worker that never answers, `deploy plan` and `help show` return at once and never ask, while `config show` asks (and waits); a config it cannot open (missing file, bearer unset, Worker down) fails only the command that needs it, naming the cause | `[unit]` `src/cli.test.ts::loadBotConfig…`, `::bindBotConfig…`, `::(a) \`deploy plan\` succeeds without ever asking for the bot config…` | | `mcpServers` as a scope setting (item 13): union of the tiers, org wins a name clash, runtime over static per tier, `config show` names them, every layer validated at load | `[unit]` `src/config.test.ts::Scope.mcpServers (MCP servers layered through config)` | | Live: a `config set me --effort low` in Slack survives a bot restart (`deploy restart`), and `npx tsx src/cli.ts config show --channel ` on a laptop shows the same override | `[agent]` `@switchboard config set me --effort low` → `deploy restart` → `@switchboard config show` still shows `effort low` for you; the CLI `config show --channel ` renders the same scope. | -| Restricted agents admit only grantees and admins, unrestricted ones everyone; `config:write` is never a baseline | `[unit]` `src/config.test.ts::permission gates` | -| Per-repo access: open-when-absent, allowlist admits members + admins, named refusal without an executor | `[unit]` `src/config.test.ts::per-repo access (canUseRepo)`, `src/core/dispatcher.test.ts::resident repo dispatch::a canUseRepo refusal is a named reply and no executor is created` | +| Restricted agents admit only grantees and admins, unrestricted ones through human baselines; credentials require explicit grants; `config:write` is never a baseline | `[unit]` `src/config.test.ts::permission gates` | +| Per-repo access: human open-when-absent, credentials explicitly scoped, allowlist admits members + admins, named refusal without an executor | `[unit]` `src/config.test.ts::per-repo access (canUseRepo)`, `src/core/dispatcher.test.ts::resident repo dispatch::a canUseRepo refusal is a named reply and no executor is created` | | Repo management fail-closed: `repo:write` is never a baseline — admins only until granted; `repo list` open | `[unit]` `src/config.test.ts::repo management gate (canManageRepos)`, `src/core/commands/repo.test.ts::gates … and scopes`, `src/core/dispatcher.test.ts::repo management commands …` | | Config commands never call a model; `help` is the derived help; `config set me --model …` / `--effort` / `--efforts.` persist and reflect in the awareness block; bad values refused inline with the shared wording; `--instructions` on `config set` is a usage reply | `[unit]` `src/core/dispatcher.test.ts::answers config commands inline…`, `::config awareness in the system prompt::*`, `::custom instructions in the system prompt::\`instructions\` is its own command…`; the commands themselves: `src/core/commands/config.test.ts::*`, `src/core/commands/help.test.ts::*` | | Item 10: stage A is the only fast path — the natural op forms are prose there (handed on, nothing replied, no history fetched, no run) and the fast-path module exports nothing that reads prose | `[unit]` `src/core/dispatch/fastPath.test.ts::stage A is the only fast path — no natural form is recognized without the model::*` | diff --git a/src/channels/commandHttp.test.ts b/src/channels/commandHttp.test.ts index 3c0280585..421adce51 100644 --- a/src/channels/commandHttp.test.ts +++ b/src/channels/commandHttp.test.ts @@ -572,6 +572,7 @@ describe("callerIdFor — one Access identity → caller id mapping for /api and actions: new Set(["runs:read", "memory:write", "mcp:write", "steer:write"]), channels: new Set(), repos: new Set(), + repoAccess: { except: new Set() }, }, }); expect((await callerFor(readerBot, opts)).actor).toEqual({ diff --git a/src/config.test.ts b/src/config.test.ts index 88803bc69..050d55ace 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -610,8 +610,12 @@ describe("grantsFor — the grants the policy table decides on", () => { ), { commandGroups: ["runs", "friction"] }, ); + const openAgents = Object.keys(AGENTS) + .filter((name) => name !== "coding") + .map((name) => `agent:run:${name}`); expect(s.grantsFor("access:alice@example.com")).toEqual({ actions: new Set([ + ...openAgents, "runs:read", "friction:read", "memory:write", @@ -622,11 +626,13 @@ describe("grantsFor — the grants the policy table decides on", () => { ]), channels: "all", repos: new Set(), + repoAccess: { except: new Set() }, }); expect(s.grantsFor("access:stranger")).toEqual({ - actions: new Set(["runs:read", "friction:read", "memory:write", "mcp:write", "steer:write"]), + actions: new Set([...openAgents, "runs:read", "friction:read", "memory:write", "mcp:write", "steer:write"]), channels: new Set(), repos: new Set(), + repoAccess: { except: new Set() }, }); expect(s.grantsFor("access:svc:reader-bot")).toEqual({ actions: new Set(["runs:read"]), @@ -634,8 +640,13 @@ describe("grantsFor — the grants the policy table decides on", () => { repos: new Set(), }); expect(s.grantsFor("access:svc:stranger")).toBe(NO_GRANTS); - // Without the catalogue's groups the store cannot spell a group read: an unlisted browser session holds nothing. - expect(store().grantsFor("access:stranger")).toBe(NO_GRANTS); + // Without command groups, open agent and code access remain; no command reads are guessed. + expect(store().grantsFor("access:stranger")).toEqual({ + actions: new Set(openAgents), + channels: new Set(), + repos: new Set(), + repoAccess: { except: new Set() }, + }); }); it("no admin at all → nobody may manage repos (still closed)", async () => { diff --git a/src/config.ts b/src/config.ts index f4b7f6c27..ec2f6c57d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,16 +17,15 @@ import { resolveMergeWatch, type MergeWatchSettings, type PullsSettings } from " import type { ReadingDiffConfig } from "./core/readingDiff.js"; import type { SpawnConfig } from "./core/dispatch/spawn.js"; import type { DashboardConfig } from "./core/dashboardAuthConfig.js"; -import { effectiveGrants, hasAction } from "./core/authz/authorize.js"; +import { authorize, effectiveGrants, hasAction } from "./core/authz/authorize.js"; import { grantsIn, grantsTable, - mayRunAgent, - mayUseRepo, type GrantsConfig, type GrantsTable, type RestrictConfig, } from "./core/authz/grants.js"; +import { resolveChatActor } from "./core/authz/actor.js"; import { isViewablePerson } from "./core/authz/viewAs.js"; import { ConfigDocumentClient, @@ -378,8 +377,8 @@ export interface AppConfig extends AgentConfiguration { /** * What is CLOSED unless a grant covers it: agents (run only by a holder of * `agent:run:`) and repos (`owner/name`, used only by a holder whose - * `repos` names it). Everything unlisted is open to everyone who can reach - * the bot. Repo management (`repo:write`) and channel config (`config:write`) + * `repos` names it). Unlisted agents and code repos are open in human baselines; + * credentials always require explicit grants. Repo management (`repo:write`) and channel config (`config:write`) * need no entry here — they are closed by construction (held only where * `grants` say so, admins through `actions: all`). */ @@ -1318,21 +1317,21 @@ export class ConfigStore { // ---- authorization gates ---------------------------------------------------- // Every gate reads the grants table (authorization.md item 9): what an actor - // holds, plus `restrict` for the two resources that are open unless listed. + // holds, with human openness already compiled into namespace baselines. // Enforcement happens at run time against the *resolved* agent and repo, so // no config layer (including "config set me") can bypass a restriction. - /** Every agent is open unless `restrict.agents` names it; a restricted agent - * runs only for a holder of `agent:run:` (admins through `all`). */ + /** The resolved actor must hold agent:run:, including its human baseline. */ canRunAgent(actor: string | Actor, agentName: string): boolean { - return mayRunAgent(this.grants, this.grantsOf(actor), agentName); + return authorize(this.actorOf(actor), "agent:run", { type: "agent", name: agentName }).allow; } - /** Every repo is open unless `restrict.repos` names it; a restricted repo is - * used only by a holder whose `repos` axis names it (admins through `all`). + /** The resolved actor must hold compiled code access to this repo. * A refused actor is refused BY NAME — never a silent per-thread fallback. */ canUseRepo(actor: string | Actor, slug: string): boolean { - return mayUseRepo(this.grants, this.grantsOf(actor), slug); + const [owner, name, extra] = slug.split("/"); + if (!owner || !name || extra !== undefined) return false; + return authorize(this.actorOf(actor), "repo:use", { type: "repo", owner, name }).allow; } /** The channel-config right, as the policy table's `config:write` row on @@ -1351,6 +1350,13 @@ export class ConfigStore { return hasAction(this.grantsOf(actor).actions, "repo:write"); } + /** String callers use the same namespace resolver as adapters. Resolved actors retain delegation. */ + private actorOf(actor: string | Actor): Actor { + return typeof actor === "string" + ? resolveChatActor({ userId: actor, channelId: "", threadKey: "" }, (id) => this.grantsFor(id)) + : actor; + } + /** What a gate decides on: an actor id's `grants` entry, or a resolved * actor's EFFECTIVE grants — the intersection for an `agent` acting on a * person's behalf (a relayed message, authorization.md item 14), its own diff --git a/src/core/authz/actor.test.ts b/src/core/authz/actor.test.ts index a346226fe..743600ac4 100644 --- a/src/core/authz/actor.test.ts +++ b/src/core/authz/actor.test.ts @@ -41,6 +41,7 @@ const source: GrantsSource = { agentNames: ["general", "coding"], commandGroups: ["runs", "friction"], }; +const humanGrants = (g: Partial): Grants => ({ ...grants(g), repoAccess: { except: set() } }); const lookup = (id: string) => grantsFor(id, source); describe("actorIdFor — platform-namespaced ids (invariant 4)", () => { @@ -76,7 +77,7 @@ describe("resolveActor — kind, id, grants, origin per surface", () => { ); expect(a.kind).toBe("user"); expect(a.id).toBe("slack:UNOBODY"); - expect(a.grants).toEqual(grants({ actions: set(...CHAT_OPEN_ACTIONS) })); + expect(a.grants).toEqual(humanGrants({ actions: set(...CHAT_OPEN_ACTIONS) })); }); it("Slack plain user when an agent is unrestricted → agent:run: for it on top of the baseline (canRunAgent today)", () => { @@ -84,7 +85,7 @@ describe("resolveActor — kind, id, grants, origin per surface", () => { if (!onlyCoding.ok) throw new Error(onlyCoding.errors.join("; ")); const open = (id: string) => grantsFor(id, { ...source, restrict: onlyCoding.restrict }); expect(resolveActor({ surface: "slack", subjectId: "UNOBODY" }, open).grants).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general") }), ); }); @@ -96,16 +97,16 @@ describe("resolveActor — kind, id, grants, origin per surface", () => { if (!everyone.ok) throw new Error(everyone.errors.join("; ")); const withSurface = (id: string) => grantsFor(id, { ...source, grants: everyone.grants }); expect(resolveActor({ surface: "slack", subjectId: "UNOBODY" }, withSurface).grants).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "runs:read"), channels: set("slack:C1") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "runs:read"), channels: set("slack:C1") }), ); expect(resolveActor({ surface: "slack", subjectId: "UMGR" }, withSurface).grants).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "runs:read", "repo:write"), channels: set("slack:C1") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "runs:read", "repo:write"), channels: set("slack:C1") }), ); }); it("Slack user granted repo:write → repo:write + friction:write on top of the baseline", () => { const a = resolveActor({ surface: "slack", subjectId: "UMGR" }, lookup); - expect(a.grants).toEqual(grants({ actions: set(...CHAT_OPEN_ACTIONS, "repo:write", "friction:write") })); + expect(a.grants).toEqual(humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "repo:write", "friction:write") })); expect(a.origin).toBeUndefined(); }); @@ -140,7 +141,7 @@ describe("resolveActor — kind, id, grants, origin per surface", () => { expect(resolveActor({ surface: "access-browser", subjectId: "op-1" }, lookup)).toEqual({ kind: "user", id: "access:op-1", - grants: grants({ + grants: humanGrants({ actions: set( "runs:read", "runs:write", @@ -156,7 +157,7 @@ describe("resolveActor — kind, id, grants, origin per surface", () => { expect(resolveActor({ surface: "access-browser", subjectId: "viewer" }, lookup)).toEqual({ kind: "user", id: "access:viewer", - grants: grants({ actions: set("runs:read", "friction:read", "memory:write", "mcp:write", "steer:write") }), + grants: humanGrants({ actions: set("runs:read", "friction:read", "memory:write", "mcp:write", "steer:write") }), }); }); diff --git a/src/core/authz/authorize.ts b/src/core/authz/authorize.ts index 5d98865e2..7d3719e70 100644 --- a/src/core/authz/authorize.ts +++ b/src/core/authz/authorize.ts @@ -12,42 +12,33 @@ import { ACTOR_KINDS, POLICY, resolveGrant, ruleTarget } from "./policy.js"; import { attributesOf, targetOfResource, type ResourceAttributes } from "./resource.js"; -import type { Action, Actor, Condition, Decision, Grants, GrantSet, Resource, Rule } from "./types.js"; +import type { Action, Actor, Condition, Decision, Grants, Resource, Rule } from "./types.js"; + +import { + hasAction, + holds, + intersectSets, + holdsRepo, + normalizedRepos, + intersectRepoAccess, + repoAccessOf, + withRepoAccess, +} from "./grantSets.js"; +export { hasAction, holds } from "./grantSets.js"; // ── grants ─────────────────────────────────────────────────────────────────── -/** Does an action grant set hold `action`, literally or through a `:*` wildcard? */ -export function hasAction(actions: GrantSet, action: string): boolean { - if (actions === "all") return true; - if (actions.has(action)) return true; - for (let i = action.lastIndexOf(":"); i > 0; i = action.lastIndexOf(":", i - 1)) { - if (actions.has(`${action.slice(0, i)}:*`)) return true; - } - return false; -} - -/** Does a literal id set (channels, repos) hold `id`? */ -export function holds(set: GrantSet, id: string): boolean { - return set === "all" || set.has(id); -} - -function intersectSets(a: GrantSet, b: GrantSet, covers: (set: GrantSet, member: string) => boolean): GrantSet { - if (a === "all") return b; - if (b === "all") return a; - const out = new Set(); - for (const member of a) if (covers(b, member)) out.add(member); - for (const member of b) if (covers(a, member)) out.add(member); - return out; -} - /** `a ∩ b`, never a superset of either side. Wildcards intersect by coverage: * `{agent:run:*} ∩ {agent:run:coding}` is `{agent:run:coding}`. */ export function intersectGrants(a: Grants, b: Grants): Grants { - return { - actions: intersectSets(a.actions, b.actions, hasAction), - channels: intersectSets(a.channels, b.channels, holds), - repos: intersectSets(a.repos, b.repos, holds), - }; + return withRepoAccess( + { + actions: intersectSets(a.actions, b.actions, hasAction), + channels: intersectSets(a.channels, b.channels, holds), + repos: intersectSets(normalizedRepos(a.repos), normalizedRepos(b.repos), holds), + }, + intersectRepoAccess(repoAccessOf(a), repoAccessOf(b)), + ); } /** The grants a decision is made against: an actor acting on behalf of a @@ -101,6 +92,7 @@ export type DenyReason = | "not-self" | "not-person" | "not-owner" + | "repo-access" | "not-all-channels"; const FAILURE_REASON: Readonly> = { @@ -109,6 +101,7 @@ const FAILURE_REASON: Readonly> = { "member-of": "not-member", "is-self": "not-self", "owner-of": "not-owner", + "repo-access": "repo-access", "all-channels": "not-all-channels", }; @@ -147,7 +140,9 @@ export function evaluateCondition( case "acts-as-person": return actsAsPerson(selfIds); case "owner-of": - return attributes.repo !== undefined && holds(grants.repos, attributes.repo); + return attributes.repo !== undefined && holdsRepo(grants.repos, attributes.repo); + case "repo-access": + return attributes.repo !== undefined && holdsRepo(repoAccessOf(grants), attributes.repo); case "all-channels": return grants.channels === "all"; } diff --git a/src/core/authz/grantSets.ts b/src/core/authz/grantSets.ts new file mode 100644 index 000000000..b4b48f103 --- /dev/null +++ b/src/core/authz/grantSets.ts @@ -0,0 +1,86 @@ +import type { Grants, GrantSet, RepoAccess } from "./types.js"; + +/** Does an action grant set hold `action`, literally or through a `:*` wildcard? */ +export function hasAction(actions: GrantSet, action: string): boolean { + if (actions === "all") return true; + if (actions.has(action)) return true; + for (let i = action.lastIndexOf(":"); i > 0; i = action.lastIndexOf(":", i - 1)) { + if (actions.has(`${action.slice(0, i)}:*`)) return true; + } + return false; +} + +/** Does a literal id set (channels, repos) hold `id`? */ +export function holds(set: GrantSet, id: string): boolean { + return set === "all" || set.has(id); +} + +export function intersectSets(a: GrantSet, b: GrantSet, covers: (set: GrantSet, member: string) => boolean): GrantSet { + if (a === "all") return b; + if (b === "all") return a; + const out = new Set(); + for (const member of a) if (covers(b, member)) out.add(member); + for (const member of b) if (covers(a, member)) out.add(member); + return out; +} + +export function unionSet(a: GrantSet, b: GrantSet): GrantSet { + if (a === "all" || b === "all") return "all"; + return new Set([...a, ...b]); +} + +/** Repository names are case-insensitive on every policy path. */ +export function normalizedRepos(repos: GrantSet): GrantSet { + return repos === "all" ? repos : new Set([...repos].map((r) => r.toLowerCase())); +} +export function repoAccessOf(grants: Grants): RepoAccess { + return grants.repoAccess ?? normalizedRepos(grants.repos); +} +function isComplement(access: RepoAccess): access is { readonly except: ReadonlySet } { + return typeof access === "object" && "except" in access; +} +export function holdsRepo(access: RepoAccess, repo: string): boolean { + const lower = repo.toLowerCase(); + if (access === "all") return true; + const names = isComplement(access) ? access.except : access; + const present = [...names].some((r) => r.toLowerCase() === lower); + return isComplement(access) ? !present : present; +} +function normalizedAccess(access: RepoAccess): RepoAccess { + return isComplement(access) + ? { except: normalizedRepos(access.except) as ReadonlySet } + : normalizedRepos(access); +} +/** Finite/complement arithmetic preserves both sides of delegation and human baselines. */ +export function intersectRepoAccess(a: RepoAccess, b: RepoAccess): RepoAccess { + a = normalizedAccess(a); + b = normalizedAccess(b); + if (a === "all") return b; + if (b === "all") return a; + if (isComplement(a) && isComplement(b)) return { except: new Set([...a.except, ...b.except]) }; + if (isComplement(a)) return new Set([...(b as ReadonlySet)].filter((r) => holdsRepo(a, r))); + return new Set([...a].filter((r) => holdsRepo(b, r))); +} +export function unionRepoAccess(a: RepoAccess, b: RepoAccess): RepoAccess { + a = normalizedAccess(a); + b = normalizedAccess(b); + if (a === "all" || b === "all") return "all"; + if (isComplement(a) && isComplement(b)) return { except: new Set([...a.except].filter((r) => b.except.has(r))) }; + if (isComplement(b)) return unionRepoAccess(b, a); + if (isComplement(a)) return { except: new Set([...a.except].filter((r) => !holdsRepo(b, r))) }; + return new Set([...a, ...b]); +} +/** Keep the historical grant shape when explicit ownership already describes code access. */ +export function withRepoAccess(grants: Grants, access: RepoAccess): Grants { + const own = normalizedRepos(grants.repos); + if ( + access === own || + (access !== "all" && + own !== "all" && + !isComplement(access) && + access.size === own.size && + [...access].every((r) => own.has(r))) + ) + return grants; + return { ...grants, repoAccess: access }; +} diff --git a/src/core/authz/grants.test.ts b/src/core/authz/grants.test.ts index 4b161a624..c8df7be82 100644 --- a/src/core/authz/grants.test.ts +++ b/src/core/authz/grants.test.ts @@ -7,8 +7,6 @@ import { grantsFor, grantsIn, grantsTable, - mayRunAgent, - mayUseRepo, namespaceBaseline, NO_RESTRICTION, parseGrantsConfig, @@ -17,6 +15,7 @@ import { type GrantsConfig, type Restriction, } from "./grants.js"; +import { authorize } from "./authorize.js"; import { NO_GRANTS, type Grants } from "./types.js"; // Feature: docs/reference/specs/authorization.md item 9 — one authorization shape: the @@ -27,6 +26,7 @@ import { NO_GRANTS, type Grants } from "./types.js"; const set = (...names: string[]) => new Set(names); const grants = (g: Partial): Grants => ({ actions: set(), channels: set(), repos: set(), ...g }); +const humanGrants = (g: Partial): Grants => ({ ...grants(g), repoAccess: { except: set() } }); const AGENTS = ["general", "coding", "review"]; const parsed = (raw: GrantsConfig) => { const p = parseGrantsConfig(raw); @@ -172,14 +172,14 @@ describe("the baselines — what an id holds by its namespace, listed or not", ( it("everyone = the open chat commands + agent:run for every UNRESTRICTED agent; config:write is never a baseline", () => { const open = grantsTable({ agentNames: AGENTS }); expect(open.everyone).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general", "agent:run:coding", "agent:run:review") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general", "agent:run:coding", "agent:run:review") }), ); expect(open.everyone.actions).not.toContain("config:write"); const locked = grantsTable({ agentNames: AGENTS, restrict: restriction({ agents: ["coding"] }) }); expect(locked.everyone.actions).toContain("agent:run:general"); expect(locked.everyone.actions).not.toContain("agent:run:coding"); // No agents registered → no agent is open to everyone. - expect(grantsTable({}).everyone).toEqual(grants({ actions: set(...CHAT_OPEN_ACTIONS) })); + expect(grantsTable({}).everyone).toEqual(humanGrants({ actions: set(...CHAT_OPEN_ACTIONS) })); }); }); @@ -196,26 +196,36 @@ describe("grantsTable / grantsIn / grantsFor — the lookup", () => { commandGroups: ["runs", "repo"], }; expect(grantsFor("slack:UMGR", source)).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general", "repo:write") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general", "repo:write") }), ); expect(grantsFor("slack:UNKNOWN", source)).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general") }), ); expect(grantsFor("access:bob", source)).toEqual( - grants({ - actions: set("runs:write", "runs:read", "repo:read", "memory:write", "mcp:write", "steer:write"), + humanGrants({ + actions: set( + "runs:write", + "runs:read", + "repo:read", + "memory:write", + "mcp:write", + "steer:write", + "agent:run:general", + ), channels: set("slack:G1"), }), ); expect(grantsFor("access:stranger", source)).toEqual( - grants({ actions: set("runs:read", "repo:read", "memory:write", "mcp:write", "steer:write") }), + humanGrants({ + actions: set("runs:read", "repo:read", "memory:write", "mcp:write", "steer:write", "agent:run:general"), + }), ); expect(grantsFor("access:svc:ops", source)).toEqual(grants({ actions: set("runs:read"), channels: "all" })); expect(grantsFor("http:ci", source)).toEqual(grants({ actions: set("dispatch") })); for (const id of ["http:stranger", "mcp:ci", "access:svc:stranger", "schedule:x"]) expect(grantsFor(id, source), id).toBe(NO_GRANTS); - // No command groups known → a browser session holds nothing (fail-closed). - expect(grantsFor("access:stranger", {})).toBe(NO_GRANTS); + // No groups or agents known: only open code access, never repo memory ownership. + expect(grantsFor("access:stranger", {})).toEqual(humanGrants({})); }); it("`all` on an axis absorbs the baseline; an admin entry resolves to ALL_GRANTS", () => { @@ -263,10 +273,13 @@ describe("surface entries — what every actor authenticated on a surface holds" commandGroups: ["runs"], }; expect(grantsFor("slack:UNOBODY", source)).toEqual( - grants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general", "runs:read"), channels: set("slack:C1") }), + humanGrants({ actions: set(...CHAT_OPEN_ACTIONS, "agent:run:general", "runs:read"), channels: set("slack:C1") }), ); expect(grantsFor("access:anyone", source)).toEqual( - grants({ actions: set("runs:read", "memory:write", "mcp:write", "steer:write", "runs:write"), channels: "all" }), + humanGrants({ + actions: set("runs:read", "memory:write", "mcp:write", "steer:write", "runs:write", "agent:run:general"), + channels: "all", + }), ); expect(grantsFor("http:anyone", source)).toEqual(grants({ actions: set("dispatch") })); // A service token is a named credential: `access:*` is browser sessions only. @@ -290,7 +303,7 @@ describe("surface entries — what every actor authenticated on a surface holds" }; const baseline = [...CHAT_OPEN_ACTIONS, "agent:run:general"]; expect(grantsFor("slack:UMORE", source)).toEqual( - grants({ + humanGrants({ actions: set(...baseline, "runs:read", "repo:write", "config:write"), channels: set("slack:C1", "slack:C2"), repos: set("acme/api"), @@ -298,14 +311,14 @@ describe("surface entries — what every actor authenticated on a surface holds" ); // Listed with LESS than the surface entry: still holds the surface entry whole. expect(grantsFor("slack:ULESS", source)).toEqual( - grants({ + humanGrants({ actions: set(...baseline, "runs:read", "repo:write"), channels: set("slack:C1"), repos: set("acme/api"), }), ); expect(grantsFor("slack:UADMIN", source)).toEqual( - grants({ actions: "all", channels: set("slack:C1"), repos: set("acme/api") }), + humanGrants({ actions: "all", channels: set("slack:C1"), repos: set("acme/api") }), ); expect(grantsFor("access:anyone", source)).toEqual(ALL_GRANTS); // The service token's own entry is all it holds — the browser wildcard is not its surface. @@ -324,9 +337,24 @@ describe("surface entries — what every actor authenticated on a surface holds" restrict: restriction({ agents: ["coding"] }), agentNames: AGENTS, }); - expect(mayRunAgent(table, grantsIn(table, "slack:UDEV"), "coding")).toBe(true); - expect(mayRunAgent(table, grantsIn(table, "slack:UNOBODY"), "coding")).toBe(true); - expect(mayRunAgent(table, grantsIn(table, "access:anyone"), "coding")).toBe(false); + expect( + authorize({ kind: "user", id: "slack:UDEV", grants: grantsIn(table, "slack:UDEV") }, "agent:run", { + type: "agent", + name: "coding", + }).allow, + ).toBe(true); + expect( + authorize({ kind: "user", id: "slack:UNOBODY", grants: grantsIn(table, "slack:UNOBODY") }, "agent:run", { + type: "agent", + name: "coding", + }).allow, + ).toBe(true); + expect( + authorize({ kind: "user", id: "access:anyone", grants: grantsIn(table, "access:anyone") }, "agent:run", { + type: "agent", + name: "coding", + }).allow, + ).toBe(false); }); it("the table keeps surface entries apart from actors: `grants` never lists a `*` key (a surface is not an actor)", () => { @@ -338,37 +366,14 @@ describe("surface entries — what every actor authenticated on a surface holds" }); }); -describe("mayRunAgent / mayUseRepo — open unless restricted, then only for a holder", () => { - const table = { restrict: restriction({ agents: ["coding"], repos: ["Acme/API"] }) }; - - it("an unrestricted agent runs for anyone, a restricted one only for a holder of agent:run:, the agent:run:* wildcard, or `all`", () => { - expect(mayRunAgent(table, NO_GRANTS, "general")).toBe(true); - expect(mayRunAgent(table, NO_GRANTS, "coding")).toBe(false); - expect(mayRunAgent(table, grants({ actions: set("agent:run:coding") }), "coding")).toBe(true); - // The `agent:run:*` wildcard the policy table honours unlocks a restricted agent here too; another agent's grant does not. - expect(mayRunAgent(table, grants({ actions: set("agent:run:*") }), "coding")).toBe(true); - expect(mayRunAgent(table, grants({ actions: set("agent:run:general") }), "coding")).toBe(false); - expect(mayRunAgent(table, ALL_GRANTS, "coding")).toBe(true); - expect(mayRunAgent({ restrict: NO_RESTRICTION }, NO_GRANTS, "coding")).toBe(true); - }); - - it("an unrestricted repo is open to anyone, a restricted one only for a holder whose repos name it (case-insensitively) or `all`", () => { - expect(mayUseRepo(table, NO_GRANTS, "acme/other")).toBe(true); - expect(mayUseRepo(table, NO_GRANTS, "acme/api")).toBe(false); - expect(mayUseRepo(table, NO_GRANTS, "Acme/API")).toBe(false); - expect(mayUseRepo(table, grants({ repos: set("acme/api") }), "ACME/api")).toBe(true); - expect(mayUseRepo(table, grants({ repos: set("Acme/API") }), "acme/api")).toBe(true); - expect(mayUseRepo(table, ALL_GRANTS, "acme/api")).toBe(true); - }); - - it("covers: `all` or the name itself", () => { +describe("literal grant coverage", () => { + it("covers: all or the name itself", () => { expect(covers("all", "anything")).toBe(true); expect(covers(set("a"), "a")).toBe(true); expect(covers(set("a"), "b")).toBe(false); }); }); -// Feature: docs/reference/specs/authorization.md item 18 (record 0062) — // `identity:write` is never a baseline: no Slack user, no browser session and // no credential holds it unlisted. It is held by `all` and by a named grants // entry alone, and `grantsFor` is otherwise untouched by the binding. diff --git a/src/core/authz/grants.ts b/src/core/authz/grants.ts index a0301f626..f1b06e615 100644 --- a/src/core/authz/grants.ts +++ b/src/core/authz/grants.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import { hasAction } from "./authorize.js"; +import { unionSet, normalizedRepos, repoAccessOf, unionRepoAccess, withRepoAccess } from "./grantSets.js"; import { NO_GRANTS, type Grants, type GrantSet } from "./types.js"; // Grants: WHAT an actor may do, from config's one shape — // the native `grants` block (one entry per platform-namespaced actor id, or // `:*` for every actor authenticated on a surface) — plus `restrict`, which // names the agents and repos that are CLOSED unless a grant covers them. -// Everything not restricted is open to everyone who can reach the bot; a grant +// Unrestricted agents and code repos are compiled into human baselines; a grant // only ever adds. Pure: no I/O, no decisions — nothing here says whether an // action is allowed; that is `authorize`. @@ -53,8 +53,7 @@ export function surfaceKeyFor(actorId: string): string | undefined { return (SURFACE_GRANT_PREFIXES as readonly string[]).includes(ns) ? `${ns}:*` : undefined; } -/** The action that lets an actor run agent `` — checked only for a - * RESTRICTED agent (`restrict.agents`); every other agent is open. */ +/** The action that lets an actor run agent `` on every surface. */ export function agentRunAction(agent: string): string { return `agent:run:${agent}`; } @@ -169,7 +168,7 @@ export function parseGrantsConfig(raw: unknown): ParsedGrantsConfig { grants.set(actorId, { actions: toSet(parsed.data.actions), channels: toSet(parsed.data.channels), - repos: toSet(parsed.data.repos), + repos: normalizedRepos(toSet(parsed.data.repos)), }); } return errors.length > 0 ? { ok: false, errors } : { ok: true, grants }; @@ -180,7 +179,7 @@ export function parseGrantsConfig(raw: unknown): ParsedGrantsConfig { /** `restrict:` in config.yaml. An agent listed here runs only for an actor whose * grants hold `agent:run:` (or `all`); a repo listed here (an `owner/name` * slug) is used only by an actor whose `repos` axis names it (or `all`). - * Everything unlisted is open to everyone who can reach the bot. The lock and + * Everything unlisted is compiled into the human code-access baseline. The lock and * the allowlist are kept apart: listing a grant never takes anything from * anyone else. */ export interface RestrictConfig { @@ -272,7 +271,7 @@ export interface GrantsTable { /** What every `slack:` user holds, listed or not: the open chat commands and `agent:run:` for every unrestricted agent. */ everyone: Grants; /** What every Access browser session (`access:`, never `access:svc:`) holds: - * each registered group's read and personal chat writes. */ + * each registered group's read, personal chat writes and unrestricted agents. */ browser: Grants; restrict: Restriction; } @@ -299,8 +298,16 @@ export function grantsTable(source: GrantsSource): GrantsTable { const restrict = source.restrict ?? NO_RESTRICTION; const openAgents = (source.agentNames ?? []).filter((a) => !restrict.agents.has(a)).map(agentRunAction); const baselines = { - everyone: { ...NO_GRANTS, actions: new Set([...CHAT_OPEN_ACTIONS, ...openAgents]) }, - browser: { ...NO_GRANTS, actions: browserActions(source.commandGroups ?? []) }, + everyone: { + ...NO_GRANTS, + actions: new Set([...CHAT_OPEN_ACTIONS, ...openAgents]), + repoAccess: { except: restrict.repos }, + }, + browser: { + ...NO_GRANTS, + actions: new Set([...browserActions(source.commandGroups ?? []), ...openAgents]), + repoAccess: { except: restrict.repos }, + }, }; const grants = new Map(); const surfaces = new Map(); @@ -333,35 +340,15 @@ export function grantsFor(actorId: string, source: GrantsSource): Grants { return grantsIn(grantsTable(source), actorId); } -/** Whether `actor` may run `agent`: every agent is open unless `restrict.agents` - * names it, and then only for a holder of `agent:run:` — literally, through - * the `agent:run:*` wildcard, or `all` (the same `hasAction` the policy table reads). */ -export function mayRunAgent(table: Pick, actorGrants: Grants, agent: string): boolean { - return !table.restrict.agents.has(agent) || hasAction(actorGrants.actions, agentRunAction(agent)); -} - -/** Whether `actor` may use repo `slug`: every repo is open unless `restrict.repos` - * names it, and then only for a holder whose `repos` axis names it (or `all`). - * Compared lowercased on both sides — slugs are case-insensitive on GitHub. */ -export function mayUseRepo(table: Pick, actorGrants: Grants, slug: string): boolean { - const lower = slug.toLowerCase(); - if (!table.restrict.repos.has(lower)) return true; - if (actorGrants.repos === "all") return true; - for (const r of actorGrants.repos) if (r.toLowerCase() === lower) return true; - return false; -} - -function unionSet(a: GrantSet, b: GrantSet): GrantSet { - if (a === "all" || b === "all") return "all"; - return new Set([...a, ...b]); -} - function unionGrants(a: Grants, b: Grants): Grants { - return { - actions: unionSet(a.actions, b.actions), - channels: unionSet(a.channels, b.channels), - repos: unionSet(a.repos, b.repos), - }; + return withRepoAccess( + { + actions: unionSet(a.actions, b.actions), + channels: unionSet(a.channels, b.channels), + repos: unionSet(normalizedRepos(a.repos), normalizedRepos(b.repos)), + }, + unionRepoAccess(repoAccessOf(a), repoAccessOf(b)), + ); } function isEmpty(g: Grants): boolean { @@ -371,6 +358,7 @@ function isEmpty(g: Grants): boolean { g.channels !== "all" && g.channels.size === 0 && g.repos !== "all" && - g.repos.size === 0 + g.repos.size === 0 && + g.repoAccess === undefined ); } diff --git a/src/core/authz/policy.test.ts b/src/core/authz/policy.test.ts index 965e89334..06680647f 100644 --- a/src/core/authz/policy.test.ts +++ b/src/core/authz/policy.test.ts @@ -343,7 +343,7 @@ const CASES: Record = [A.member, repo(REPOS[0])], ], }, - "repo:use repo [owner-of]": { + "repo:use repo [repo-access]": { allow: [ [A.manager, repo(REPOS[0])], [A.admin, repo(REPOS[1])], diff --git a/src/core/authz/policy.ts b/src/core/authz/policy.ts index 12e0112dc..834e0e49d 100644 --- a/src/core/authz/policy.ts +++ b/src/core/authz/policy.ts @@ -140,7 +140,7 @@ export const POLICY: readonly Rule[] = [ // The exec grant on a repo the actor may use (not yet asked by a command). { action: "repo:exec", resource: "repo", when: [grant("repo:exec"), OWNER_OF] }, // Binding a run to a repo; open-when-absent today → grants.repos = "all". - { action: "repo:use", resource: "repo", when: [OWNER_OF] }, + { action: "repo:use", resource: "repo", when: [{ kind: "repo-access" }] }, // ── config ─────────────────────────────────────────────────────────────── // `config show`: the read grant. @@ -293,6 +293,7 @@ export const CONDITION_KINDS: readonly Condition["kind"][] = [ "is-self", "acts-as-person", "owner-of", + "repo-access", "all-channels", ]; @@ -300,6 +301,7 @@ const REQUIRED_ATTRIBUTE: Readonly }; + case "repo-access": { + const access = repoAccessOf(grants); + if (access === "all") return { kind: "repos-not-in", repos: new Set() }; + if ("except" in access) return { kind: "repos-not-in", repos: access.except }; + return access.size === 0 ? NONE : { kind: "repos-in", repos: access }; + } case "all-channels": return grants.channels === "all" ? ALL : NONE; } @@ -178,7 +187,9 @@ export function matchesPredicate(predicate: Predicate, record: PredicateRecord): case "user-is": return record.userId !== undefined && record.userId === predicate.userId; case "repos-in": - return record.repo !== undefined && holds(predicate.repos, record.repo); + return record.repo !== undefined && holdsRepo(predicate.repos, record.repo); + case "repos-not-in": + return record.repo !== undefined && !holdsRepo(predicate.repos, record.repo); case "visibility-in": return predicate.visibilities.has(record.channelVisibility ?? "unknown"); case "or": diff --git a/src/core/authz/scopedCredentialPolicy.test.ts b/src/core/authz/scopedCredentialPolicy.test.ts new file mode 100644 index 000000000..fa5f6b5f3 --- /dev/null +++ b/src/core/authz/scopedCredentialPolicy.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { ConfigStore, InMemoryOverridesBacking, parseAppConfigText } from "../../config.js"; +import { chatActorOf, resolveActor } from "./actor.js"; +import { authorize, effectiveGrants } from "./authorize.js"; +import { predicateFor, matchesPredicate } from "./predicate.js"; +import { attributesOf } from "./resource.js"; +import { isRunVisibilityFilter, matchesVisibility, toVisibilityFilter } from "../runRecord.js"; +import { ALL_GRANTS, grantsFor } from "./grants.js"; +import type { Actor, Grants, RepoAccess, Resource } from "./types.js"; + +// Feature: docs/reference/specs/authorization.md — Credential scope proof. +function config(restricted = false): ConfigStore { + const validated = parseAppConfigText(` +organization: acme +providers: + openai: { wire: openai-responses, apiKeyEnv: OPENAI_API_KEY } +defaults: + agent: general + models: + general: openai/general-model + explore: openai/explore-model + review: openai/review-model + coding: openai/coding-model +grants: + "http:fixture": + actions: [dispatch, agent:run:general, agent:run:explore, agent:run:review] + channels: ["http:fixture"] + repos: ["Acme/Fixture"] + "slack:UADMIN": { actions: all, channels: all, repos: all } + "slack:UDEV": { actions: [agent:run:coding], repos: ["Acme/Closed"] } +${restricted ? 'restrict:\n agents: [coding]\n repos: ["Acme/Closed"]' : ""} +`); + return new ConfigStore({ validated }, { backing: new InMemoryOverridesBacking(), initial: undefined }); +} +function actor(store: ConfigStore, surface: "http" | "slack" | "access-browser", subjectId: string): Actor { + return resolveActor({ surface, subjectId }, (id) => store.grantsFor(id)); +} +const repo = (slug: string): Resource => { + const [owner, name] = slug.split("/"); + return { type: "repo", owner: owner!, name: name! }; +}; +const memory = (slug: string): Resource => ({ type: "memory-scope", kind: "repo", key: `repo:${slug}` }); + +describe("compiled credential authority", () => { + it("the resolved service runs only its three granted agents and fixture repo on an unrestricted deployment", () => { + const s = config(); + const service = actor(s, "http", "fixture"); + expect(service.kind).toBe("service"); + expect(s.canRunAgent(service, "coding")).toBe(false); + for (const name of ["general", "explore", "review"]) { + expect(s.canRunAgent(service, name)).toBe(true); + expect(authorize(service, "agent:run", { type: "agent", name }).allow).toBe(true); + } + expect(s.canUseRepo(service, "acme/customer")).toBe(false); + expect(s.canUseRepo(service, "ACME/FIXTURE")).toBe(true); + for (const action of ["memory:read", "memory:write"]) { + expect(authorize(service, action, memory("acme/customer")).allow).toBe(false); + } + expect(s.canRunAgent("http:unlisted", "general")).toBe(false); + expect(s.canUseRepo("http:unlisted", "acme/fixture")).toBe(false); + }); + + it("human open and restricted defaults preserve memory ownership and admin and CLI authority", () => { + for (const restricted of [false, true]) { + const s = config(restricted); + for (const person of [actor(s, "slack", "UOTHER"), actor(s, "access-browser", "browser")]) { + expect(s.canRunAgent(person, "coding")).toBe(!restricted); + expect(s.canUseRepo(person, "acme/closed")).toBe(!restricted); + expect(s.canUseRepo(person, "acme/open")).toBe(true); + for (const action of ["memory:read", "memory:write"]) { + expect(authorize(person, action, memory("acme/open")).allow).toBe(false); + } + } + const dev = actor(s, "slack", "UDEV"); + expect(s.canUseRepo(dev, "ACME/CLOSED")).toBe(true); + expect(authorize(dev, "memory:read", memory("acme/closed")).allow).toBe(true); + expect(s.canRunAgent(actor(s, "slack", "UADMIN"), "coding")).toBe(true); + const cli = resolveActor({ surface: "cli", subjectId: "local" }, () => { + throw new Error("unexpected lookup"); + }); + expect(s.canUseRepo(cli, "acme/customer")).toBe(true); + } + }); + + it("a credential bound to an admin and mixed nested delegation stay within the credential ceiling", () => { + const s = config(); + const service = actor(s, "http", "fixture"); + const person = actor(s, "slack", "UADMIN"); + const bound = chatActorOf(s, { + userId: person.id, + authenticatedAs: service.id, + channelId: "http:fixture", + threadKey: "http:fixture:1", + }); + const relay = chatActorOf(s, { + userId: person.id, + postedBy: service.id, + channelId: "http:fixture", + threadKey: "http:fixture:1", + }); + const nested: Actor = { kind: "agent", id: "agent:review", grants: ALL_GRANTS, onBehalfOf: relay }; + const reversed: Actor = { ...person, kind: "agent", onBehalfOf: service }; + for (const a of [bound, relay, nested, reversed]) { + expect(s.canRunAgent(a, "coding")).toBe(false); + expect(s.canUseRepo(a, "acme/customer")).toBe(false); + expect(s.canRunAgent(a, "review")).toBe(true); + expect(s.canUseRepo(a, "acme/fixture")).toBe(true); + expect(authorize(a, "memory:write", memory("acme/customer")).allow).toBe(false); + } + }); + + it("finite and complementary delegation intersects code access without lending memory ownership", () => { + const domains: RepoAccess[] = [ + "all", + new Set(), + new Set(["Acme/A"]), + new Set(["acme/b", "acme/c"]), + { except: new Set() }, + { except: new Set(["Acme/A"]) }, + { except: new Set(["acme/b", "acme/c"]) }, + ]; + const make = (access: RepoAccess, id: string): Actor => ({ + kind: "agent", + id, + grants: { actions: "all", channels: "all", repos: new Set(["acme/owned"]), repoAccess: access }, + }); + for (const left of domains) + for (const right of domains) { + const a = make(left, "agent:a"); + const b = make(right, "agent:b"); + const delegated = { ...a, onBehalfOf: b }; + expect(effectiveGrants(delegated).repos).toEqual(new Set(["acme/owned"])); + for (const slug of ["acme/a", "ACME/B", "acme/c", "acme/owned", "acme/foreign"]) { + const target = repo(slug); + const expected = authorize(a, "repo:use", target).allow && authorize(b, "repo:use", target).allow; + expect(authorize(delegated, "repo:use", target).allow).toBe(expected); + expect(matchesPredicate(predicateFor(delegated, "repo:use", "repo"), attributesOf(target))).toBe(expected); + expect(authorize(delegated, "memory:read", memory(slug)).allow).toBe(slug === "acme/owned"); + } + } + // Union of two human floors and explicit grants removes only explicitly covered exclusions. + const grants = grantsFor("slack:U", { + restrict: { agents: new Set(), repos: new Set(["acme/a", "acme/b"]) }, + grants: new Map([ + ["slack:U", { actions: new Set(), channels: new Set(), repos: new Set(["Acme/A"]) }], + ]), + }); + const human: Actor = { kind: "user", id: "slack:U", grants }; + expect(authorize(human, "repo:use", repo("acme/a")).allow).toBe(true); + expect(authorize(human, "repo:use", repo("acme/b")).allow).toBe(false); + expect(authorize(human, "repo:use", repo("acme/c")).allow).toBe(true); + expect(authorize(human, "memory:read", memory("acme/c")).allow).toBe(false); + }); + + it("point, predicate and serialized filters agree on complements, intersections, case and missing repos", () => { + const s = config(true); + const person = actor(s, "slack", "UOTHER"); + const service = actor(s, "http", "fixture"); + const delegated: Actor = { ...service, kind: "agent", onBehalfOf: person }; + for (const a of [person, service, delegated]) { + const predicate = predicateFor(a, "repo:use", "repo"); + const wire = toVisibilityFilter(predicate); + expect(isRunVisibilityFilter(wire)).toBe(true); + for (const slug of ["acme/open", "acme/closed", "acme/fixture", "ACME/FIXTURE"]) { + const target = repo(slug); + const point = authorize(a, "repo:use", target).allow; + expect(matchesPredicate(predicate, attributesOf(target))).toBe(point); + expect(matchesVisibility(wire, { channelId: "http:fixture", userId: a.id, repo: slug })).toBe(point); + expect(s.canUseRepo(a, slug)).toBe(point); + } + expect(matchesPredicate(predicate, {})).toBe(false); + expect(matchesVisibility(wire, { channelId: "http:fixture", userId: a.id })).toBe(false); + } + expect(isRunVisibilityFilter({ kind: "repos-not-in", repos: "all" })).toBe(false); + expect(isRunVisibilityFilter({ kind: "not", of: [{ kind: "all" }] })).toBe(false); + }); +}); diff --git a/src/core/authz/types.ts b/src/core/authz/types.ts index fa5983e60..e97fe3978 100644 --- a/src/core/authz/types.ts +++ b/src/core/authz/types.ts @@ -14,6 +14,10 @@ export type ActorKind = "user" | "service" | "schedule" | "agent"; /** A set of names, or everything. `"all"` is explicit, never a default. */ export type GrantSet = ReadonlySet | "all"; +/** Compiled code access: literal repos, all, or every repo except a finite set. + * Only human namespace baselines create complements; config retains its literal shape. */ +export type RepoAccess = GrantSet | { readonly except: ReadonlySet }; + /** What an actor may do. One shape for humans, ingress * tokens, Access identities, schedule actors, and agents. */ export interface Grants { @@ -21,8 +25,10 @@ export interface Grants { readonly actions: GrantSet; /** Platform-namespaced channel ids the actor is a member of (`slack:C…`, `http:ops`), or every channel. */ readonly channels: GrantSet; - /** `owner/name` repos the actor may use, or every repo (open-when-absent today → `"all"`). */ + /** `owner/name` repos the actor explicitly owns, including repo memory, or every repo. */ readonly repos: GrantSet; + /** Code access, distinct from memory ownership. Absent inherits the explicit repos axis. */ + readonly repoAccess?: RepoAccess; } // Which agents an actor may run is NOT a separate axis: it is the action // `agent:run:` (or `agent:run:*`) in `actions`, so `has-grant` covers it @@ -140,6 +146,8 @@ export type Condition = | { readonly kind: "acts-as-person" } /** actor.grants.repos contains the resource's repo (or is "all"). */ | { readonly kind: "owner-of" } + /** Compiled code access covers the resource repo; never grants memory ownership. */ + | { readonly kind: "repo-access" } /** actor.grants.channels === "all". */ | { readonly kind: "all-channels" }; @@ -190,6 +198,7 @@ export type Predicate = | { readonly kind: "channels-in"; readonly channelIds: ReadonlySet } | { readonly kind: "user-is"; readonly userId: string } | { readonly kind: "repos-in"; readonly repos: ReadonlySet } + | { readonly kind: "repos-not-in"; readonly repos: ReadonlySet } /** The record's stamped `channelVisibility` is one of these (`member-of`'s * public half). A record without the stamp is `unknown` and never matches * `visibility-in(["public"])`. */ diff --git a/src/core/dispatch/admission.test.ts b/src/core/dispatch/admission.test.ts index 3196d9916..3db67b50d 100644 --- a/src/core/dispatch/admission.test.ts +++ b/src/core/dispatch/admission.test.ts @@ -80,6 +80,8 @@ defaults: coding: anthropic/coding-model grants: "slack:UADMIN": { actions: all, channels: all, repos: all } + "http:t1": { actions: [steer:write, agent:run:general] } + "mcp:alice": { actions: [steer:write, agent:run:general] } restrict: agents: [coding] `; diff --git a/src/core/dispatcher.test.ts b/src/core/dispatcher.test.ts index ba824ecdd..7e7e3f633 100644 --- a/src/core/dispatcher.test.ts +++ b/src/core/dispatcher.test.ts @@ -1861,6 +1861,8 @@ describe("dispatch", () => { }, }; const deps = makeDeps(mainDmYaml, provider); + // Isolate the missing source identity from the repository admission gate. + deps.config.canUseRepo = () => true; deps.githubApi = new InMemoryGithubApi({ "not-a-repo": { private: false } }); const { io, replies } = mainDmIO(); await dispatch(deps, mainDm("which repos can I reach?"), io); @@ -10233,6 +10235,7 @@ describe("input / context / answer events in the run stream", () => { const history: HistoryItem[] = [{ role: "user", text: "earlier: 1 < 2 <@U777|dana>" }]; const { events } = await runWith(raw, { history, + yaml: YAML_FIXTURE.replace("restrict:\n", ' "http:ops": { actions: [agent:run:general] }\nrestrict:\n'), message: { channelId: "http:ops", userId: "http:ops", threadKey: "http:ops:1" }, }); const [input, context] = textEventsOf(events); @@ -11330,8 +11333,8 @@ grants: "slack:UADMIN": { actions: all, channels: all, repos: all } "slack:UDEV": { actions: [agent:run:coding] } "slack:UREV": { repos: ["acme/api"] } - "http:job": { actions: [dispatch, agent:run:coding], channels: ["http:ops"], repos: ["acme/api"] } - "mcp:job": { actions: [dispatch, agent:run:coding], channels: ["mcp:ops"], repos: ["acme/api"] } + "http:job": { actions: [dispatch, agent:run:ship, agent:run:review, agent:run:coding], channels: ["http:ops"], repos: ["acme/api"] } + "mcp:job": { actions: [dispatch, agent:run:ship, agent:run:review, agent:run:coding], channels: ["mcp:ops"], repos: ["acme/api"] } restrict: agents: [coding] repos: ["acme/api"] @@ -11410,8 +11413,13 @@ workspaceDir: __WORKDIR__ }); it("channel guard: agent:ship over a handle without openThread is refused before the runner", async () => { - const { deps, provider, created } = shipDeps(); - // An unrestricted repo (open-when-absent), so the CHANNEL refusal is the + const { deps, provider, created } = shipDeps( + SHIP_YAML.replace( + "restrict:\n", + ' "http:token-ci": { actions: [agent:run:ship, agent:run:review, agent:run:coding], repos: ["acme/web"] }\nrestrict:\n', + ), + ); + // Explicit repo and agent grants ensure the CHANNEL refusal is the // one that fires — not the repo allowlist, which has its own test above. deps.resolveRepoContext = () => ({ repo: "acme/web" }); const { io, replies } = fakeIO(); diff --git a/src/core/memory/reflection.ts b/src/core/memory/reflection.ts index a01e67484..25e58ce5d 100644 --- a/src/core/memory/reflection.ts +++ b/src/core/memory/reflection.ts @@ -281,6 +281,7 @@ export function reflectionActor(principal: Actor, run: { channelId?: string; rep return { ...principal, grants: { + ...principal.grants, actions: principal.grants.actions, channels: channels === "all" || run.channelId === undefined ? channels : new Set([...channels, run.channelId]), repos: repos === "all" || run.repo === undefined ? repos : new Set([...repos, run.repo]), diff --git a/src/core/runRecord.ts b/src/core/runRecord.ts index 54cdbdf86..ca94393fd 100644 --- a/src/core/runRecord.ts +++ b/src/core/runRecord.ts @@ -956,6 +956,7 @@ export type RunVisibilityFilter = | { kind: "channels-in"; channelIds: string[] } | { kind: "user-is"; userId: string } | { kind: "repos-in"; repos: string[] } + | { kind: "repos-not-in"; repos: string[] } | { kind: "visibility-in"; visibilities: ChannelVisibility[] } | { kind: "or"; of: RunVisibilityFilter[] } | { kind: "and"; of: RunVisibilityFilter[] }; @@ -973,7 +974,8 @@ export function toVisibilityFilter(predicate: Predicate): RunVisibilityFilter { case "user-is": return { kind: "user-is", userId: predicate.userId }; case "repos-in": - return { kind: "repos-in", repos: [...predicate.repos].sort() }; + case "repos-not-in": + return { kind: predicate.kind, repos: [...predicate.repos].map((r) => r.toLowerCase()).sort() }; case "visibility-in": return { kind: "visibility-in", visibilities: [...predicate.visibilities].sort() }; case "or": @@ -1005,6 +1007,7 @@ export function isRunVisibilityFilter(v: unknown, depth = 0): v is RunVisibility case "user-is": return typeof f.userId === "string" && f.userId.length > 0; case "repos-in": + case "repos-not-in": return isStringList(f.repos, MAX_FILTER_IDS); case "visibility-in": return ( @@ -1037,7 +1040,9 @@ export function matchesVisibility( case "user-is": return row.userId === filter.userId; case "repos-in": - return row.repo !== undefined && filter.repos.includes(row.repo); + return row.repo !== undefined && filter.repos.some((r) => r.toLowerCase() === row.repo!.toLowerCase()); + case "repos-not-in": + return row.repo !== undefined && !filter.repos.some((r) => r.toLowerCase() === row.repo!.toLowerCase()); case "visibility-in": return filter.visibilities.includes(row.channelVisibility ?? "unknown"); case "or": diff --git a/src/core/testing/callers.ts b/src/core/testing/callers.ts index 68ee9a828..878e58e39 100644 --- a/src/core/testing/callers.ts +++ b/src/core/testing/callers.ts @@ -12,7 +12,12 @@ function toGrants(g: CallerGrants): Grants { if (g === "all") return { actions: "all", channels: "all", repos: "all" }; if (Array.isArray(g)) return { ...NO_GRANTS, actions: new Set(g as readonly string[]), channels: "all" }; const over = g as Partial; - return { actions: over.actions ?? new Set(), channels: over.channels ?? new Set(), repos: over.repos ?? new Set() }; + return { + actions: over.actions ?? new Set(), + channels: over.channels ?? new Set(), + repos: over.repos ?? new Set(), + ...(over.repoAccess !== undefined ? { repoAccess: over.repoAccess } : {}), + }; } /** A credential (`mcp:`, `http:`, `access:svc:`) is a `service`; everything else a `user`. */