@nullsquare/agent-authority 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -9
- package/ROADMAP.md +13 -5
- package/docs/npm-release.md +10 -6
- package/docs/release-v0.4.7.md +20 -11
- package/examples/task-first-coding.js +238 -0
- package/package.json +5 -3
- package/src/providers/github-coding.js +101 -0
- package/src/providers/github.js +178 -9
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
**Agent Authority is a small execution layer that lets an agent use existing account permissions only for the task the user actually gave it.**
|
|
10
10
|
|
|
11
|
-
[Quickstart](docs/quickstart.md) · [Task-first API](#task-first-api) · [Product proof gate](docs/product-proof.md) · [Task Leases](docs/task-leases.md) · [Durability](docs/durable-task-leases.md) · [Evidence](docs/evidence.md) · [Transport invariance](docs/transport-invariance.md) · [Roadmap](ROADMAP.md)
|
|
11
|
+
[Quickstart](docs/quickstart.md) · [Connected GitHub](docs/connected-github.md) · [Task-first API](#task-first-api) · [Product proof gate](docs/product-proof.md) · [Task Leases](docs/task-leases.md) · [Durability](docs/durable-task-leases.md) · [Evidence](docs/evidence.md) · [Transport invariance](docs/transport-invariance.md) · [Roadmap](ROADMAP.md)
|
|
12
12
|
|
|
13
|
-
> **Status: public pre-alpha / v0.4.
|
|
13
|
+
> **Status: public pre-alpha / v0.4.7 Developer Preview on npm.** The task-first API is published as `@nullsquare/agent-authority/task`. Agent Authority is not production-ready yet.
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
@@ -79,7 +79,7 @@ PASS -> useful task work ran; unrelated standing permission did not become task
|
|
|
79
79
|
|
|
80
80
|
The quickstart uses the real published `createTask()` API and reviewed GitHub authority extractor. Only the provider callback is a local provider-shaped fixture so the first run needs no account.
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
The fresh-install path is continuously checked from blank Node 20 consumers against the public npm package. That proves the published package supports the documented task-first surface; it does **not** replace the still-open first-time-human under-10-minute adoption test.
|
|
83
83
|
|
|
84
84
|
### Next: make one real GitHub call, still with no credential
|
|
85
85
|
|
|
@@ -103,9 +103,51 @@ PASS -> broader standing repo.read permission could not reach an unrelated repos
|
|
|
103
103
|
|
|
104
104
|
This deliberately models the account/app capability as broader than the task. Mission-level `repo.read` can read repositories generally, while the Task authority root allows this task to reach only `Null-Square/agent-authority`. The first request makes one real public GitHub `fetch()`; the unrelated repository is stopped before a second network call.
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
### Then: connect GitHub without putting the credential in task context
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
v0.4.7 adds the connected-provider task path. Initialize the local runtime and pipe a GitHub credential on stdin:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx agent-authority setup
|
|
112
|
+
printf %s "$GITHUB_TOKEN" | npx agent-authority connect github --token-stdin
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then an application can use the same task-first surface with broker-owned provider execution:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
import { createRuntimeEnvironment } from '@nullsquare/agent-authority/runtime-env';
|
|
119
|
+
import { createTask } from '@nullsquare/agent-authority/task';
|
|
120
|
+
|
|
121
|
+
const env = createRuntimeEnvironment();
|
|
122
|
+
|
|
123
|
+
const task = createTask({
|
|
124
|
+
principal: env.config.principal_id,
|
|
125
|
+
agent: 'agent:assistant',
|
|
126
|
+
request: 'Inspect only acme/private',
|
|
127
|
+
permissions: {
|
|
128
|
+
github: { allow: ['repo.read'], constraints: {} }
|
|
129
|
+
},
|
|
130
|
+
authority: {
|
|
131
|
+
repository: { kind: 'github.repository', value: 'acme/private' }
|
|
132
|
+
},
|
|
133
|
+
bindings: [
|
|
134
|
+
{ service: 'github', action: 'repo.read', field: 'repository', authority: 'repository' }
|
|
135
|
+
],
|
|
136
|
+
runtime: env.runtime
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const result = await task.execute({
|
|
140
|
+
service: 'github',
|
|
141
|
+
action: 'repo.read',
|
|
142
|
+
context: { repository: 'acme/private' }
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`task.run(request, callback)` is for application-owned SDK/provider effects. `task.execute(request)` is for Agent Authority connected-provider execution, where credential resolution stays behind the broker boundary. Both use the same Task Lease semantics and the same deny/step-up error model.
|
|
147
|
+
|
|
148
|
+
The connected GitHub CI proof packs the candidate into a fresh Node 20 consumer, creates a fresh Agent Authority home, pipes the repository's GitHub Actions installation token through stdin, confirms no plaintext token appears under the home, executes one authorized live GitHub request, and blocks an unrelated repository at the Task Lease. A fine-grained PAT or GitHub App token can use the same path for repositories that credential is allowed to access. Public CI does **not** claim independent access to an unrelated private repository.
|
|
149
|
+
|
|
150
|
+
See [Fresh-install quickstart](docs/quickstart.md) and [Connected GitHub](docs/connected-github.md).
|
|
109
151
|
|
|
110
152
|
## Task-first API
|
|
111
153
|
|
|
@@ -195,6 +237,8 @@ Example output:
|
|
|
195
237
|
The task established authority for 42 but this action requested 7.
|
|
196
238
|
```
|
|
197
239
|
|
|
240
|
+
For a connected provider, the same error/explanation flow applies to `task.execute(request)`. Successful connected execution also returns an ALLOW receipt and execution evidence, so reviewed provider outputs can feed `task.authorityFrom()` without copying the provider credential into the task.
|
|
241
|
+
|
|
198
242
|
The task-first API is a facade over the existing Mission, Task Lease, execution-evidence and guard primitives. It does not weaken or replace them.
|
|
199
243
|
|
|
200
244
|
## Run the product demo
|
|
@@ -313,7 +357,7 @@ MCP gateway
|
|
|
313
357
|
brokered provider execution
|
|
314
358
|
```
|
|
315
359
|
|
|
316
|
-
A real Vercel AI SDK `ToolLoopAgent` integration also exercises the protected-tool boundary. See [Transport invariance](docs/transport-invariance.md).
|
|
360
|
+
The task-first facade can now use both application-owned execution (`task.run`) and broker-owned connected execution (`task.execute`) without changing the underlying Task Lease. A real Vercel AI SDK `ToolLoopAgent` integration also exercises the protected-tool boundary. See [Transport invariance](docs/transport-invariance.md) and [Connected execution](docs/connected-execution-api.md).
|
|
317
361
|
|
|
318
362
|
## Durability
|
|
319
363
|
|
|
@@ -357,10 +401,12 @@ See [Durable Task Leases](docs/durable-task-leases.md).
|
|
|
357
401
|
- automatic durable Task Lease sessions;
|
|
358
402
|
- task-first public facade and deterministic utility regression gate;
|
|
359
403
|
- self-contained support/communications and operations/finance product proofs;
|
|
360
|
-
- blank-project fixture quickstart against the
|
|
404
|
+
- blank-project fixture quickstart against the public npm package;
|
|
361
405
|
- blank-project real public GitHub onboarding with broader standing permission and narrower task authority;
|
|
406
|
+
- encrypted connected-GitHub onboarding through `task.execute()` with the credential kept broker-internal;
|
|
407
|
+
- safe sole-account default resolution while multiple connected accounts remain explicit/fail closed;
|
|
362
408
|
- Node 20/22 CI, coverage, packed-consumer validation and CodeQL;
|
|
363
|
-
- independent npm registry consumer verification.
|
|
409
|
+
- independent npm registry consumer verification, including v0.4.7 connected execution.
|
|
364
410
|
|
|
365
411
|
The lower-level evidence is documented under `docs/` and remains available for security review.
|
|
366
412
|
|
|
@@ -390,7 +436,8 @@ This is still a validation implementation.
|
|
|
390
436
|
- Source-data changes do not yet automatically invalidate already-derived authority.
|
|
391
437
|
- Approved authority deltas are surfaced but not automatically applied into a live durable task.
|
|
392
438
|
- Current Task Lease bindings are exact equality. The finance proof therefore steps up for a partial refund as well as an over-refund; a derived numeric ceiling remains an evidence-driven product question rather than a general policy language.
|
|
393
|
-
-
|
|
439
|
+
- The local encrypted credential vault and stdin GitHub connection are trusted-host developer onboarding, not production OAuth/KMS credential lifecycle.
|
|
440
|
+
- Public CI proves authenticated connected execution against the current repository but does not independently demonstrate access to an unrelated private repository.
|
|
394
441
|
- Remote authenticated deployment and production approval UX remain incomplete.
|
|
395
442
|
|
|
396
443
|
These are real limitations. They are not reasons to build every possible infrastructure layer before product adoption is proven.
|
package/ROADMAP.md
CHANGED
|
@@ -30,8 +30,9 @@ The engine has enough depth to test whether developers actually want this layer.
|
|
|
30
30
|
- [x] task-first facade over Mission + Task Lease + Guard
|
|
31
31
|
- [x] explicit service permissions without requiring hand-authored Mission JSON
|
|
32
32
|
- [x] named task authority roots
|
|
33
|
-
- [x] `task.run()` guarded effect boundary
|
|
34
|
-
- [x] `task.
|
|
33
|
+
- [x] `task.run()` guarded application-owned effect boundary
|
|
34
|
+
- [x] `task.execute()` connected-provider effect boundary with broker-owned credential resolution
|
|
35
|
+
- [x] `task.authorityFrom()` strict evidence-derived authority from `run()` or successful `execute()` output
|
|
35
36
|
- [x] task-first binding of named authority to later effects
|
|
36
37
|
- [x] human-readable authority-delta explanation
|
|
37
38
|
- [x] same task-first calls can opt into durable local state by adding a store
|
|
@@ -42,6 +43,7 @@ The engine has enough depth to test whether developers actually want this layer.
|
|
|
42
43
|
- [x] self-contained operations/finance proof: ticket -> order -> payment -> exact full refund
|
|
43
44
|
- [x] automated blank-project quickstart against the current published npm package
|
|
44
45
|
- [x] blank-project real-provider quickstart: broad standing `repo.read` -> one task-authorized public GitHub repository
|
|
46
|
+
- [x] encrypted connected GitHub onboarding: stdin credential -> local vault/broker -> `task.execute()` -> live provider request
|
|
45
47
|
- [ ] coding workflow: issue -> branch -> files -> PR, with merge/deploy outside authority
|
|
46
48
|
- [ ] support/communications expansion: customer -> meeting + reply/CRM, or live task-first Google Actions proof
|
|
47
49
|
- [ ] bounded finance refund: derived payment amount can authorize a smaller refund without authorizing an over-refund
|
|
@@ -54,9 +56,11 @@ The support/communications proof uses the same task-first API across Gmail and C
|
|
|
54
56
|
|
|
55
57
|
The operations/finance proof keeps one evidence-derived chain from support ticket -> order -> payment -> exact refund. The exact payment ID, amount in minor units, and currency are all bound before the refund callback can execute. Unrelated payment, over-refund, wrong currency, partial refund under the current equality model, and post-completion refund all execute zero additional refund callbacks. This proof exposed a deliberate product gap: current bindings are exact equality, so a legitimate partial refund also steps up. Do not add a general expression language; add a narrow derived numeric ceiling only when real workflow/adoption evidence shows partial refunds are required.
|
|
56
58
|
|
|
57
|
-
The fixture fresh-install quickstart is independently exercised from
|
|
59
|
+
The fixture fresh-install quickstart is independently exercised from blank Node 20 projects against the public npm package. The release-facing registry verifier for v0.4.7 installs that exact package, confirms the optional AI SDK is absent, and runs the ordinary public consumer contract plus the connected-execution consumer contract. This is automated compatibility evidence, not a substitute for the still-open first-time-human under-10-minute test.
|
|
58
60
|
|
|
59
|
-
The live
|
|
61
|
+
The live credential-free quickstart keeps Mission-level `github:repo.read` broader than the task, binds Task authority to `Null-Square/agent-authority`, performs one real public GitHub API call, and produces `authority_delta_required` for `octocat/Hello-World` before a second `fetch()` can run.
|
|
62
|
+
|
|
63
|
+
v0.4.7 adds the authenticated connected path without creating an OAuth platform: a GitHub credential is accepted on stdin, stored in the encrypted trusted-local-host vault, resolved only inside the broker/provider runtime, and used by `task.execute()`. The live CI proof uses the repository's GitHub Actions installation token, confirms the raw token is not present in public task/connection state or plaintext under the Agent Authority home, executes the task-authorized repository read, and blocks the unrelated repository before connected provider execution. A fine-grained PAT or GitHub App token can use the same path for repositories it is allowed to access; public CI does not independently claim access to an unrelated private repository.
|
|
60
64
|
|
|
61
65
|
Current utility regression metrics:
|
|
62
66
|
|
|
@@ -158,6 +162,7 @@ Do **not** build a general semantic policy language around this primitive.
|
|
|
158
162
|
- [x] same Task Lease through direct guard/SDK execution
|
|
159
163
|
- [x] same Task Lease through MCP gateway
|
|
160
164
|
- [x] same Task Lease through brokered provider execution
|
|
165
|
+
- [x] task-first connected-provider execution through `task.execute()`
|
|
161
166
|
- [x] real Vercel AI SDK protected-tool path
|
|
162
167
|
- [x] interoperability/adversarial vectors across transports
|
|
163
168
|
|
|
@@ -169,7 +174,10 @@ Prioritize only the UX needed by successful P0 workflows.
|
|
|
169
174
|
|
|
170
175
|
- [x] credential-free fresh-install quickstart validated against the current public npm package
|
|
171
176
|
- [x] one low-friction real provider onboarding path: public GitHub read from a blank npm project
|
|
172
|
-
- [
|
|
177
|
+
- [x] authenticated connected-provider onboarding path: GitHub stdin credential + encrypted local vault + `task.execute()`
|
|
178
|
+
- [x] safe sole-account default resolution; multiple connected accounts require explicit selection
|
|
179
|
+
- [ ] independent private-repository onboarding proof with a credential that has private-repo access
|
|
180
|
+
- [ ] production provider credential lifecycle / OAuth or GitHub App onboarding where real users require it
|
|
173
181
|
- [ ] compact approval UI showing the exact authority delta
|
|
174
182
|
- [ ] automatic short-lived agent session bootstrap where needed
|
|
175
183
|
- [ ] framework integration starter focused on task-first API
|
package/docs/npm-release.md
CHANGED
|
@@ -13,14 +13,14 @@ Before any publication:
|
|
|
13
13
|
After publication, verify from a fresh project with:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm install @nullsquare/agent-authority@0.4.
|
|
16
|
+
npm install @nullsquare/agent-authority@0.4.7
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Then run the same consumer smoke
|
|
19
|
+
Then run the same consumer smoke flows through the registry-installed package. Registry verification is part of the release gate; a successful `npm publish` command alone is not sufficient.
|
|
20
20
|
|
|
21
21
|
The repository includes `.github/workflows/verify-npm-registry.yml`, which verifies registry visibility, a clean Node.js 20 install, and current public behavior from the registry artifact.
|
|
22
22
|
|
|
23
|
-
For v0.4.
|
|
23
|
+
For v0.4.7 the registry consumer exercises the existing v0.4.6 contract plus connected provider execution:
|
|
24
24
|
|
|
25
25
|
- `createTask()` from `@nullsquare/agent-authority/task`;
|
|
26
26
|
- explicit task permissions and named authority roots;
|
|
@@ -30,11 +30,15 @@ For v0.4.6 the consumer exercises:
|
|
|
30
30
|
- execution evidence and the reviewed Google/GitHub authority extractors;
|
|
31
31
|
- `ExecutingAuthorityRuntime.executeTaskLease()` and `MissionMcpGateway` transport surfaces;
|
|
32
32
|
- `JsonFileTaskLeaseStore`, `DurableTaskLeaseSession`, and the lower-level Task Lease APIs;
|
|
33
|
+
- `task.execute(request)` for connected-provider execution;
|
|
34
|
+
- the public `@nullsquare/agent-authority/runtime-env` export;
|
|
35
|
+
- safe sole-account connection resolution while multiple active accounts remain ambiguous/fail closed;
|
|
36
|
+
- connected execution where the credential is broker-internal, the task-authorized resource executes, and an unrelated resource is blocked before a second provider call;
|
|
33
37
|
- the requirement that the optional `ai` package is not installed as a production dependency.
|
|
34
38
|
|
|
35
|
-
This makes the registry artifact verification cover
|
|
39
|
+
This makes the registry artifact verification cover both application-owned task effects (`task.run`) and broker-owned connected provider effects (`task.execute`) together with the durability/evidence/transport surfaces they compose.
|
|
36
40
|
|
|
37
|
-
The v0.4.
|
|
41
|
+
The v0.4.7 independent registry verification passed after publication: npm visibility succeeded, a fresh Node.js 20 consumer installed the exact `@nullsquare/agent-authority@0.4.7` artifact, the optional AI SDK was absent, and both the ordinary package smoke and connected-execution smoke passed.
|
|
38
42
|
|
|
39
43
|
The deterministic task utility fixture is also part of the source-release gate. It currently requires:
|
|
40
44
|
|
|
@@ -52,7 +56,7 @@ This fixture is a regression gate, not a real-world performance benchmark.
|
|
|
52
56
|
Publishing to the public npm registry does not automatically create either a GitHub Release or a GitHub Packages entry.
|
|
53
57
|
|
|
54
58
|
- **npm registry** — `npm publish --access public` publishes `@nullsquare/agent-authority` to `registry.npmjs.org` / npmjs.com. This is the package users install with `npm install`.
|
|
55
|
-
- **GitHub Releases** — a separate GitHub object, normally backed by a Git tag such as `v0.4.
|
|
59
|
+
- **GitHub Releases** — a separate GitHub object, normally backed by a Git tag such as `v0.4.7`. A release must be created explicitly or by release automation.
|
|
56
60
|
- **GitHub Packages** — a separate package registry. It only appears when the package is published to GitHub's npm registry (`npm.pkg.github.com`); publishing to npmjs.com does not populate it.
|
|
57
61
|
|
|
58
62
|
Agent Authority currently uses npmjs.com as its public package registry. Therefore an empty GitHub **Packages** section is expected unless the project intentionally adopts dual publication. A GitHub **Release** is still useful for source-release discoverability and should track published versions, but it is independent from npm publication.
|
package/docs/release-v0.4.7.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# v0.4.7 connected
|
|
1
|
+
# v0.4.7 connected execution
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
v0.4.7 closes a concrete adoption gap between the task-first API and the existing credential broker/provider runtime.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What shipped
|
|
6
6
|
|
|
7
7
|
- `task.execute(request)` executes through an `ExecutingAuthorityRuntime` while preserving the current Task Lease as the narrowest authority object.
|
|
8
8
|
- connected deny/step-up outcomes use the same public error classes as `task.run()`.
|
|
@@ -11,19 +11,28 @@ This candidate closes a concrete adoption gap between the task-first API and the
|
|
|
11
11
|
- a sole active provider account can be resolved when requests omit `account_id`; multiple active accounts remain ambiguous and fail closed.
|
|
12
12
|
- default disconnect can remove that sole connection without requiring the caller to know an auto-detected provider account ID.
|
|
13
13
|
- the connected GitHub quickstart proves the local encrypted vault + credential broker + live provider path without copying credentials into task/model context.
|
|
14
|
+
- the CLI version is now derived from `package.json` instead of a stale hard-coded constant.
|
|
14
15
|
|
|
15
16
|
## Security boundary
|
|
16
17
|
|
|
17
|
-
This does not create a GitHub token, OAuth flow, GitHub App, KMS, or new identity format. Provider-side least privilege still comes from GitHub. Agent Authority adds a task boundary underneath that connected account authority.
|
|
18
|
+
This release does not create a GitHub token, OAuth flow, GitHub App, KMS, or new identity format. Provider-side least privilege still comes from GitHub. Agent Authority adds a task boundary underneath that connected account authority.
|
|
18
19
|
|
|
19
20
|
The local encrypted vault remains a trusted-local-host developer reference backend.
|
|
20
21
|
|
|
21
|
-
## Release
|
|
22
|
+
## Release evidence
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
The exact v0.4.7 candidate passed:
|
|
24
25
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
26
|
+
- Node 20 and Node 22 test lanes;
|
|
27
|
+
- coverage;
|
|
28
|
+
- packed-package consumer smoke;
|
|
29
|
+
- connected-execution packed consumer smoke;
|
|
30
|
+
- Vercel AI SDK integration;
|
|
31
|
+
- live GitHub read proof;
|
|
32
|
+
- live evidence-derived GitHub mutation proof;
|
|
33
|
+
- encrypted connected-GitHub onboarding proof;
|
|
34
|
+
- CodeQL.
|
|
35
|
+
|
|
36
|
+
After merge, the independent `Verify npm registry` workflow resolved version `0.4.7`, confirmed it was visible on npm, installed that exact registry artifact into a fresh Node 20 consumer, confirmed the optional AI SDK was absent, and successfully ran both the ordinary public package smoke and the connected-execution smoke.
|
|
37
|
+
|
|
38
|
+
That registry proof is the basis for marking v0.4.7 published.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { AuthorityApprovalRequiredError, AuthorityDeniedError } from '../src/guard.js';
|
|
2
|
+
import {
|
|
3
|
+
githubGitRefShaAuthorityExtractor,
|
|
4
|
+
githubIssueListSelectedNumberAuthorityExtractor,
|
|
5
|
+
githubPullRequestCreateNumberAuthorityExtractor
|
|
6
|
+
} from '../src/providers/github.js';
|
|
7
|
+
import {
|
|
8
|
+
githubContentsWritePathAuthorityExtractor,
|
|
9
|
+
githubGitRefCreateBranchAuthorityExtractor
|
|
10
|
+
} from '../src/providers/github-coding.js';
|
|
11
|
+
import { createTask } from '../src/task.js';
|
|
12
|
+
|
|
13
|
+
const repository = 'acme/app';
|
|
14
|
+
const marker = 'coding-fixture-42';
|
|
15
|
+
const baseBranch = 'main';
|
|
16
|
+
const plannedBranch = 'agent/issue-42';
|
|
17
|
+
const targetPath = 'src/auth.js';
|
|
18
|
+
const baseSha = 'a'.repeat(40);
|
|
19
|
+
|
|
20
|
+
const task = createTask({
|
|
21
|
+
principal: 'user:demo',
|
|
22
|
+
agent: 'agent:coder',
|
|
23
|
+
request: 'Fix the selected issue on one task branch, change only src/auth.js, and open a draft PR. Do not merge or deploy.',
|
|
24
|
+
permissions: {
|
|
25
|
+
github: {
|
|
26
|
+
allow: ['issue.list', 'git.ref.read', 'git.ref.create', 'repo.contents.write', 'pull_request.create'],
|
|
27
|
+
deny: ['pull_request.merge', 'repo.delete'],
|
|
28
|
+
constraints: {}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
authority: {
|
|
32
|
+
repository: { kind: 'github.repository', value: repository },
|
|
33
|
+
fixture_marker: { kind: 'github.issue.marker', value: marker },
|
|
34
|
+
base_branch: { kind: 'github.git.branch', value: baseBranch },
|
|
35
|
+
planned_branch: { kind: 'github.git.branch.intent', value: plannedBranch },
|
|
36
|
+
target_path: { kind: 'github.repository.path.intent', value: targetPath }
|
|
37
|
+
},
|
|
38
|
+
bindings: [
|
|
39
|
+
{ service: 'github', action: 'issue.list', field: 'repository', authority: 'repository' },
|
|
40
|
+
{ service: 'github', action: 'issue.list', field: 'fixture_marker', authority: 'fixture_marker' },
|
|
41
|
+
{ service: 'github', action: 'git.ref.read', field: 'repository', authority: 'repository' },
|
|
42
|
+
{ service: 'github', action: 'git.ref.read', field: 'branch', authority: 'base_branch' },
|
|
43
|
+
{ service: 'github', action: 'git.ref.create', field: 'repository', authority: 'repository' },
|
|
44
|
+
{ service: 'github', action: 'git.ref.create', field: 'branch', authority: 'planned_branch' },
|
|
45
|
+
{ service: 'github', action: 'git.ref.create', field: 'sha', authority: 'base_sha' },
|
|
46
|
+
{ service: 'github', action: 'git.ref.create', field: 'issue_number', authority: 'issue' },
|
|
47
|
+
{ service: 'github', action: 'repo.contents.write', field: 'repository', authority: 'repository' },
|
|
48
|
+
{ service: 'github', action: 'repo.contents.write', field: 'branch', authority: 'task_branch' },
|
|
49
|
+
{ service: 'github', action: 'repo.contents.write', field: 'path', authority: 'target_path' },
|
|
50
|
+
{ service: 'github', action: 'repo.contents.write', field: 'issue_number', authority: 'issue' },
|
|
51
|
+
{ service: 'github', action: 'pull_request.create', field: 'repository', authority: 'repository' },
|
|
52
|
+
{ service: 'github', action: 'pull_request.create', field: 'head', authority: 'task_branch' },
|
|
53
|
+
{ service: 'github', action: 'pull_request.create', field: 'base', authority: 'base_branch' },
|
|
54
|
+
{ service: 'github', action: 'pull_request.create', field: 'issue_number', authority: 'issue' },
|
|
55
|
+
{ service: 'github', action: 'pull_request.create', field: 'changed_path', authority: 'changed_file' }
|
|
56
|
+
]
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let reads = 0;
|
|
60
|
+
let mutations = 0;
|
|
61
|
+
|
|
62
|
+
const discovery = await task.run({
|
|
63
|
+
service: 'github',
|
|
64
|
+
action: 'issue.list',
|
|
65
|
+
context: { repository, fixture_marker: marker, state: 'open' }
|
|
66
|
+
}, async () => {
|
|
67
|
+
reads += 1;
|
|
68
|
+
return {
|
|
69
|
+
provider: 'github',
|
|
70
|
+
selected_issue_number: 42,
|
|
71
|
+
selected_issue_title: 'Fix auth edge case',
|
|
72
|
+
selected_issue_match_count: 1,
|
|
73
|
+
selected_issue_marker: marker
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const issue = task.authorityFrom(discovery, {
|
|
77
|
+
name: 'issue',
|
|
78
|
+
kind: 'github.issue.number',
|
|
79
|
+
from: ['repository', 'fixture_marker'],
|
|
80
|
+
extractor: githubIssueListSelectedNumberAuthorityExtractor
|
|
81
|
+
});
|
|
82
|
+
console.log(`DISCOVER -> issue #${issue.value} became task authority`);
|
|
83
|
+
|
|
84
|
+
const base = await task.run({
|
|
85
|
+
service: 'github',
|
|
86
|
+
action: 'git.ref.read',
|
|
87
|
+
context: { repository, branch: baseBranch }
|
|
88
|
+
}, async () => {
|
|
89
|
+
reads += 1;
|
|
90
|
+
return { provider: 'github', branch: baseBranch, ref: `refs/heads/${baseBranch}`, sha: baseSha };
|
|
91
|
+
});
|
|
92
|
+
const baseAuthority = task.authorityFrom(base, {
|
|
93
|
+
name: 'base_sha',
|
|
94
|
+
kind: 'github.git.sha',
|
|
95
|
+
from: ['repository', 'base_branch'],
|
|
96
|
+
extractor: githubGitRefShaAuthorityExtractor
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const branch = await task.run({
|
|
100
|
+
service: 'github',
|
|
101
|
+
action: 'git.ref.create',
|
|
102
|
+
context: { repository, branch: plannedBranch, sha: baseAuthority.value, issue_number: issue.value }
|
|
103
|
+
}, async () => {
|
|
104
|
+
mutations += 1;
|
|
105
|
+
return { provider: 'github', branch: plannedBranch, ref: `refs/heads/${plannedBranch}`, sha: baseSha };
|
|
106
|
+
});
|
|
107
|
+
const taskBranch = task.authorityFrom(branch, {
|
|
108
|
+
name: 'task_branch',
|
|
109
|
+
kind: 'github.git.branch',
|
|
110
|
+
from: ['planned_branch', 'issue', 'base_sha'],
|
|
111
|
+
extractor: githubGitRefCreateBranchAuthorityExtractor
|
|
112
|
+
});
|
|
113
|
+
console.log(`ALLOW -> created only task branch ${taskBranch.value}`);
|
|
114
|
+
|
|
115
|
+
async function expectStepUp(request, label) {
|
|
116
|
+
const before = mutations;
|
|
117
|
+
try {
|
|
118
|
+
await task.run(request, async () => { mutations += 1; });
|
|
119
|
+
throw new Error(`${label} unexpectedly executed`);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!(error instanceof AuthorityApprovalRequiredError)) throw error;
|
|
122
|
+
if (mutations !== before) throw new Error(`${label} reached the provider callback`);
|
|
123
|
+
console.log(`STEP-UP -> ${label}: ${task.explain(error).summary}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await expectStepUp({
|
|
128
|
+
service: 'github',
|
|
129
|
+
action: 'repo.contents.write',
|
|
130
|
+
context: {
|
|
131
|
+
repository,
|
|
132
|
+
branch: baseBranch,
|
|
133
|
+
path: targetPath,
|
|
134
|
+
issue_number: issue.value,
|
|
135
|
+
message: 'wrong branch',
|
|
136
|
+
content_base64: 'd3Jvbmc='
|
|
137
|
+
}
|
|
138
|
+
}, 'write directly to main');
|
|
139
|
+
|
|
140
|
+
await expectStepUp({
|
|
141
|
+
service: 'github',
|
|
142
|
+
action: 'repo.contents.write',
|
|
143
|
+
context: {
|
|
144
|
+
repository,
|
|
145
|
+
branch: taskBranch.value,
|
|
146
|
+
path: 'src/admin.js',
|
|
147
|
+
issue_number: issue.value,
|
|
148
|
+
message: 'wrong file',
|
|
149
|
+
content_base64: 'd3Jvbmc='
|
|
150
|
+
}
|
|
151
|
+
}, 'change an unrelated file');
|
|
152
|
+
|
|
153
|
+
const write = await task.run({
|
|
154
|
+
service: 'github',
|
|
155
|
+
action: 'repo.contents.write',
|
|
156
|
+
context: {
|
|
157
|
+
repository,
|
|
158
|
+
branch: taskBranch.value,
|
|
159
|
+
path: targetPath,
|
|
160
|
+
issue_number: issue.value,
|
|
161
|
+
message: 'Fix issue #42',
|
|
162
|
+
content_base64: Buffer.from('export const fixed = true;\n').toString('base64')
|
|
163
|
+
}
|
|
164
|
+
}, async () => {
|
|
165
|
+
mutations += 1;
|
|
166
|
+
return {
|
|
167
|
+
provider: 'github',
|
|
168
|
+
body: { content: { path: targetPath, sha: 'b'.repeat(40) }, commit: { sha: 'c'.repeat(40) } }
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
const changedFile = task.authorityFrom(write, {
|
|
172
|
+
name: 'changed_file',
|
|
173
|
+
kind: 'github.repository.path',
|
|
174
|
+
from: ['task_branch', 'issue', 'target_path'],
|
|
175
|
+
extractor: githubContentsWritePathAuthorityExtractor
|
|
176
|
+
});
|
|
177
|
+
console.log(`ALLOW -> changed only ${changedFile.value} on ${taskBranch.value}`);
|
|
178
|
+
|
|
179
|
+
await expectStepUp({
|
|
180
|
+
service: 'github',
|
|
181
|
+
action: 'pull_request.create',
|
|
182
|
+
context: {
|
|
183
|
+
repository,
|
|
184
|
+
head: 'agent/unrelated',
|
|
185
|
+
base: baseBranch,
|
|
186
|
+
issue_number: issue.value,
|
|
187
|
+
changed_path: changedFile.value,
|
|
188
|
+
title: 'Wrong PR head',
|
|
189
|
+
draft: true
|
|
190
|
+
}
|
|
191
|
+
}, 'open PR from another branch');
|
|
192
|
+
|
|
193
|
+
const pr = await task.run({
|
|
194
|
+
service: 'github',
|
|
195
|
+
action: 'pull_request.create',
|
|
196
|
+
context: {
|
|
197
|
+
repository,
|
|
198
|
+
head: taskBranch.value,
|
|
199
|
+
base: baseBranch,
|
|
200
|
+
issue_number: issue.value,
|
|
201
|
+
changed_path: changedFile.value,
|
|
202
|
+
title: 'Fix issue #42',
|
|
203
|
+
draft: true
|
|
204
|
+
}
|
|
205
|
+
}, async () => {
|
|
206
|
+
mutations += 1;
|
|
207
|
+
return {
|
|
208
|
+
provider: 'github',
|
|
209
|
+
pull_request_number: 77,
|
|
210
|
+
head: taskBranch.value,
|
|
211
|
+
base: baseBranch,
|
|
212
|
+
draft: true
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
const pullRequest = task.authorityFrom(pr, {
|
|
216
|
+
name: 'pull_request',
|
|
217
|
+
kind: 'github.pull_request.number',
|
|
218
|
+
from: ['issue', 'task_branch', 'changed_file'],
|
|
219
|
+
extractor: githubPullRequestCreateNumberAuthorityExtractor
|
|
220
|
+
});
|
|
221
|
+
console.log(`ALLOW -> opened draft PR #${pullRequest.value}`);
|
|
222
|
+
|
|
223
|
+
const beforeMerge = mutations;
|
|
224
|
+
try {
|
|
225
|
+
await task.run({
|
|
226
|
+
service: 'github',
|
|
227
|
+
action: 'pull_request.merge',
|
|
228
|
+
context: { repository, pull_request_number: pullRequest.value }
|
|
229
|
+
}, async () => { mutations += 1; });
|
|
230
|
+
throw new Error('merge unexpectedly executed');
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (!(error instanceof AuthorityDeniedError)) throw error;
|
|
233
|
+
if (mutations !== beforeMerge) throw new Error('merge reached provider callback');
|
|
234
|
+
console.log('DENY -> merge remains outside task authority');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
task.complete('draft PR opened');
|
|
238
|
+
console.log(`PASS -> reads=${reads}, authorized mutations=${mutations}; unrelated writes/PRs and merge executed zero callbacks`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nullsquare/agent-authority",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "Task-bounded authority runtime for AI agents: give agents tasks, not standing account permissions.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"doctor": "node src/cli.js doctor",
|
|
34
34
|
"demo": "node examples/demo.js",
|
|
35
35
|
"demo:task": "node examples/task-first-github.js",
|
|
36
|
+
"demo:task-coding": "node examples/task-first-coding.js",
|
|
36
37
|
"demo:task-lease": "node examples/task-lease-demo.js",
|
|
37
38
|
"demo:live-github": "node examples/live-github-task-lease.js",
|
|
38
39
|
"demo:live-derived-github": "node examples/live-github-derived-mutation.js",
|
|
@@ -44,9 +45,9 @@
|
|
|
44
45
|
"test": "node --test test/*.test.js",
|
|
45
46
|
"test:ai-sdk": "node --test test/integrations/ai-sdk.integration.mjs",
|
|
46
47
|
"test:coverage": "node --experimental-test-coverage --test test/*.test.js",
|
|
47
|
-
"check:syntax": "node --check src/index.js && node --check src/authority-evidence.js && node --check src/connections.js && node --check src/execution.js && node --check src/providers/github.js && node --check src/providers/google.js && node --check src/storage.js && node --check src/durable-task-lease.js && node --check src/task.js && node --check src/runtime-env.js && node --check src/sdk.js && node --check src/server.js && node --check src/cli.js && node --check src/agent-auth.js && node --check src/approvals.js && node --check src/idempotency.js && node --check src/keys.js && node --check src/harness-bridge.js && node --check src/guard.js && node --check src/task-lease.js && node --check src/mcp-gateway.js && node --check src/mcp-remote.js && node --check src/mcp-server.js && node --check src/integrations/ai-sdk.js && node --check examples/validation-mcp-upstream.js && node --check examples/direct-guard.js && node --check examples/task-first-github.js && node --check examples/task-lease-demo.js && node --check examples/live-github-task-lease.js && node --check examples/live-github-derived-mutation.js && node --check examples/live-google-cross-provider.js && node --check examples/quickstart-github-connected.mjs && node --check benchmarks/task-utility.mjs",
|
|
48
|
+
"check:syntax": "node --check src/index.js && node --check src/authority-evidence.js && node --check src/connections.js && node --check src/execution.js && node --check src/providers/github.js && node --check src/providers/github-coding.js && node --check src/providers/google.js && node --check src/storage.js && node --check src/durable-task-lease.js && node --check src/task.js && node --check src/runtime-env.js && node --check src/sdk.js && node --check src/server.js && node --check src/cli.js && node --check src/agent-auth.js && node --check src/approvals.js && node --check src/idempotency.js && node --check src/keys.js && node --check src/harness-bridge.js && node --check src/guard.js && node --check src/task-lease.js && node --check src/mcp-gateway.js && node --check src/mcp-remote.js && node --check src/mcp-server.js && node --check src/integrations/ai-sdk.js && node --check examples/validation-mcp-upstream.js && node --check examples/direct-guard.js && node --check examples/task-first-github.js && node --check examples/task-first-coding.js && node --check examples/task-lease-demo.js && node --check examples/live-github-task-lease.js && node --check examples/live-github-derived-mutation.js && node --check examples/live-google-cross-provider.js && node --check examples/quickstart-github-connected.mjs && node --check benchmarks/task-utility.mjs",
|
|
48
49
|
"check:package": "npm pack --dry-run",
|
|
49
|
-
"check": "npm run check:syntax && npm test && npm run demo:task && npm run benchmark:task && npm run demo:task-lease && npm run check:package"
|
|
50
|
+
"check": "npm run check:syntax && npm test && npm run demo:task && npm run demo:task-coding && npm run benchmark:task && npm run demo:task-lease && npm run check:package"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
@@ -80,6 +81,7 @@
|
|
|
80
81
|
"./task": "./src/task.js",
|
|
81
82
|
"./task-lease": "./src/task-lease.js",
|
|
82
83
|
"./providers/github": "./src/providers/github.js",
|
|
84
|
+
"./providers/github-coding": "./src/providers/github-coding.js",
|
|
83
85
|
"./providers/google": "./src/providers/google.js"
|
|
84
86
|
},
|
|
85
87
|
"files": ["src", "docs", "examples", "benchmarks", "README.md", "LICENSE", "SECURITY.md", "ROADMAP.md", "CONTRIBUTING.md"],
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
function providerError(code, message) {
|
|
2
|
+
const error = new Error(message);
|
|
3
|
+
error.code = code;
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function safeBranch(value, name = 'branch') {
|
|
8
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 255) {
|
|
9
|
+
throw providerError('trusted_extractor_output_invalid', `${name} must be a non-empty Git branch name`);
|
|
10
|
+
}
|
|
11
|
+
const segments = value.split('/');
|
|
12
|
+
const invalidCharacter = [...value].some((char) => {
|
|
13
|
+
const code = char.charCodeAt(0);
|
|
14
|
+
return code <= 32 || code === 127 || '~^:?*[\\'.includes(char);
|
|
15
|
+
});
|
|
16
|
+
if (
|
|
17
|
+
value === '@' ||
|
|
18
|
+
value.startsWith('/') ||
|
|
19
|
+
value.endsWith('/') ||
|
|
20
|
+
value.startsWith('.') ||
|
|
21
|
+
value.endsWith('.') ||
|
|
22
|
+
value.includes('//') ||
|
|
23
|
+
value.includes('..') ||
|
|
24
|
+
value.includes('@{') ||
|
|
25
|
+
invalidCharacter ||
|
|
26
|
+
segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.endsWith('.lock'))
|
|
27
|
+
) {
|
|
28
|
+
throw providerError('trusted_extractor_output_invalid', `${name} is not a safe Git branch name`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safePath(value, name = 'path') {
|
|
34
|
+
if (typeof value !== 'string' || value.length === 0 || value.startsWith('/') || value.endsWith('/') || value.includes('\\')) {
|
|
35
|
+
throw providerError('trusted_extractor_output_invalid', `${name} must be a relative repository path`);
|
|
36
|
+
}
|
|
37
|
+
const segments = value.split('/');
|
|
38
|
+
if (
|
|
39
|
+
segments.some((segment) => !segment || segment === '.' || segment === '..') ||
|
|
40
|
+
[...value].some((char) => {
|
|
41
|
+
const code = char.charCodeAt(0);
|
|
42
|
+
return code === 0 || code === 127;
|
|
43
|
+
})
|
|
44
|
+
) {
|
|
45
|
+
throw providerError('trusted_extractor_output_invalid', `${name} contains an unsafe path segment`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function gitSha(value, name = 'sha') {
|
|
51
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{40}$/i.test(value)) {
|
|
52
|
+
throw providerError('trusted_extractor_output_invalid', `${name} must be a 40-character Git SHA`);
|
|
53
|
+
}
|
|
54
|
+
return value.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Establish downstream task authority for the exact branch GitHub confirms was
|
|
59
|
+
* created by an already-authorized git.ref.create operation.
|
|
60
|
+
*/
|
|
61
|
+
export function githubGitRefCreateBranchAuthorityExtractor({ receipt, output } = {}) {
|
|
62
|
+
if (receipt?.service !== 'github' || receipt?.action !== 'git.ref.create') {
|
|
63
|
+
throw providerError(
|
|
64
|
+
'trusted_extractor_operation_mismatch',
|
|
65
|
+
'GitHub created-branch extractor only accepts github:git.ref.create receipts'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (output?.provider !== 'github') {
|
|
69
|
+
throw providerError('trusted_extractor_output_invalid', 'normalized GitHub ref output is required');
|
|
70
|
+
}
|
|
71
|
+
const branch = safeBranch(output.branch, 'normalized GitHub branch');
|
|
72
|
+
if (output.ref !== `refs/heads/${branch}`) {
|
|
73
|
+
throw providerError('trusted_extractor_output_invalid', 'normalized GitHub ref does not match its branch');
|
|
74
|
+
}
|
|
75
|
+
gitSha(output.sha, 'normalized GitHub ref sha');
|
|
76
|
+
return {
|
|
77
|
+
extractor_id: 'github.git.ref.create.branch.v1',
|
|
78
|
+
selector: 'output.branch'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Establish downstream task authority for the exact repository path GitHub
|
|
84
|
+
* reports as changed by an already-authorized repo.contents.write operation.
|
|
85
|
+
*/
|
|
86
|
+
export function githubContentsWritePathAuthorityExtractor({ receipt, output } = {}) {
|
|
87
|
+
if (receipt?.service !== 'github' || receipt?.action !== 'repo.contents.write') {
|
|
88
|
+
throw providerError(
|
|
89
|
+
'trusted_extractor_operation_mismatch',
|
|
90
|
+
'GitHub changed-path extractor only accepts github:repo.contents.write receipts'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (output?.provider !== 'github' || !output?.body?.content) {
|
|
94
|
+
throw providerError('trusted_extractor_output_invalid', 'GitHub contents-write output is required');
|
|
95
|
+
}
|
|
96
|
+
safePath(output.body.content.path, 'GitHub changed path');
|
|
97
|
+
return {
|
|
98
|
+
extractor_id: 'github.repo.contents.write.path.v1',
|
|
99
|
+
selector: 'output.body.content.path'
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/providers/github.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { brokeredProviderAdapter } from '../connections.js';
|
|
2
2
|
|
|
3
|
-
const MUTATING_ACTIONS = new Set([
|
|
3
|
+
const MUTATING_ACTIONS = new Set([
|
|
4
|
+
'issue.create',
|
|
5
|
+
'issue.comment',
|
|
6
|
+
'git.ref.create',
|
|
7
|
+
'pull_request.create',
|
|
8
|
+
'repo.contents.write'
|
|
9
|
+
]);
|
|
4
10
|
const ISSUE_STATES = new Set(['open', 'closed', 'all']);
|
|
5
11
|
|
|
6
12
|
function required(value, name) {
|
|
@@ -21,14 +27,69 @@ function repoParts(context = {}) {
|
|
|
21
27
|
return { owner, repo };
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
function repositoryPath(value, name = 'context.path') {
|
|
31
|
+
const path = String(required(value, name));
|
|
32
|
+
if (path.startsWith('/') || path.endsWith('/') || path.includes('\\')) {
|
|
33
|
+
throw providerError('invalid_repository_path', `${name} must be a relative repository path`);
|
|
34
|
+
}
|
|
35
|
+
const segments = path.split('/');
|
|
36
|
+
if (
|
|
37
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..') ||
|
|
38
|
+
[...path].some((char) => {
|
|
39
|
+
const code = char.charCodeAt(0);
|
|
40
|
+
return code === 0 || code === 127;
|
|
41
|
+
})
|
|
42
|
+
) {
|
|
43
|
+
throw providerError('invalid_repository_path', `${name} contains an unsafe path segment`);
|
|
44
|
+
}
|
|
45
|
+
return path;
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
function encodedPath(path) {
|
|
25
|
-
return
|
|
49
|
+
return repositoryPath(path)
|
|
26
50
|
.split('/')
|
|
27
|
-
.filter(Boolean)
|
|
28
51
|
.map(encodeURIComponent)
|
|
29
52
|
.join('/');
|
|
30
53
|
}
|
|
31
54
|
|
|
55
|
+
function branchName(value, name = 'context.branch') {
|
|
56
|
+
const branch = String(required(value, name));
|
|
57
|
+
const segments = branch.split('/');
|
|
58
|
+
const hasInvalidCharacter = [...branch].some((char) => {
|
|
59
|
+
const code = char.charCodeAt(0);
|
|
60
|
+
return code <= 32 || code === 127 || '~^:?*[\\'.includes(char);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
branch.length > 255 ||
|
|
65
|
+
branch === '@' ||
|
|
66
|
+
branch.startsWith('/') ||
|
|
67
|
+
branch.endsWith('/') ||
|
|
68
|
+
branch.startsWith('.') ||
|
|
69
|
+
branch.endsWith('.') ||
|
|
70
|
+
branch.includes('//') ||
|
|
71
|
+
branch.includes('..') ||
|
|
72
|
+
branch.includes('@{') ||
|
|
73
|
+
hasInvalidCharacter ||
|
|
74
|
+
segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.endsWith('.lock'))
|
|
75
|
+
) {
|
|
76
|
+
throw providerError('invalid_git_branch', `${name} is not a safe Git branch name`);
|
|
77
|
+
}
|
|
78
|
+
return branch;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function encodedBranch(value, name = 'context.branch') {
|
|
82
|
+
return branchName(value, name).split('/').map(encodeURIComponent).join('/');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function gitSha(value, name = 'context.sha') {
|
|
86
|
+
const sha = String(required(value, name));
|
|
87
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
|
|
88
|
+
throw providerError('invalid_git_sha', `${name} must be a 40-character Git SHA`);
|
|
89
|
+
}
|
|
90
|
+
return sha.toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
32
93
|
function issueNumber(value) {
|
|
33
94
|
const number = Number(value);
|
|
34
95
|
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
@@ -59,8 +120,8 @@ function buildOperation(request) {
|
|
|
59
120
|
return { method: 'GET', path: root };
|
|
60
121
|
|
|
61
122
|
case 'repo.contents.read': {
|
|
62
|
-
const path =
|
|
63
|
-
const query = context.ref ? `?ref=${encodeURIComponent(context.ref)}` : '';
|
|
123
|
+
const path = repositoryPath(context.path);
|
|
124
|
+
const query = context.ref ? `?ref=${encodeURIComponent(branchName(context.ref, 'context.ref'))}` : '';
|
|
64
125
|
return { method: 'GET', path: `${root}/contents/${encodedPath(path)}${query}` };
|
|
65
126
|
}
|
|
66
127
|
|
|
@@ -86,21 +147,38 @@ function buildOperation(request) {
|
|
|
86
147
|
body: { body: required(context.body, 'context.body') }
|
|
87
148
|
};
|
|
88
149
|
|
|
150
|
+
case 'git.ref.read': {
|
|
151
|
+
const branch = encodedBranch(context.branch);
|
|
152
|
+
return { method: 'GET', path: `${root}/git/ref/heads/${branch}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'git.ref.create': {
|
|
156
|
+
const branch = branchName(context.branch);
|
|
157
|
+
return {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
path: `${root}/git/refs`,
|
|
160
|
+
body: {
|
|
161
|
+
ref: `refs/heads/${branch}`,
|
|
162
|
+
sha: gitSha(context.sha)
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
89
167
|
case 'pull_request.create':
|
|
90
168
|
return {
|
|
91
169
|
method: 'POST',
|
|
92
170
|
path: `${root}/pulls`,
|
|
93
171
|
body: {
|
|
94
172
|
title: required(context.title, 'context.title'),
|
|
95
|
-
head:
|
|
96
|
-
base:
|
|
173
|
+
head: branchName(context.head, 'context.head'),
|
|
174
|
+
base: branchName(context.base, 'context.base'),
|
|
97
175
|
body: context.body || undefined,
|
|
98
176
|
draft: Boolean(context.draft)
|
|
99
177
|
}
|
|
100
178
|
};
|
|
101
179
|
|
|
102
180
|
case 'repo.contents.write': {
|
|
103
|
-
const path =
|
|
181
|
+
const path = repositoryPath(context.path);
|
|
104
182
|
const content = required(context.content_base64, 'context.content_base64');
|
|
105
183
|
return {
|
|
106
184
|
method: 'PUT',
|
|
@@ -109,7 +187,7 @@ function buildOperation(request) {
|
|
|
109
187
|
message: required(context.message, 'context.message'),
|
|
110
188
|
content,
|
|
111
189
|
sha: context.sha || undefined,
|
|
112
|
-
branch: context.branch
|
|
190
|
+
branch: context.branch ? branchName(context.branch) : undefined
|
|
113
191
|
}
|
|
114
192
|
};
|
|
115
193
|
}
|
|
@@ -158,6 +236,40 @@ function normalizeIssueList(request, body) {
|
|
|
158
236
|
};
|
|
159
237
|
}
|
|
160
238
|
|
|
239
|
+
function normalizeGitRef(request, body) {
|
|
240
|
+
const expectedBranch = branchName(request.context?.branch);
|
|
241
|
+
const ref = typeof body?.ref === 'string' ? body.ref : null;
|
|
242
|
+
const expectedRef = `refs/heads/${expectedBranch}`;
|
|
243
|
+
if (ref !== expectedRef) {
|
|
244
|
+
throw providerError('github_git_ref_invalid', `GitHub ${request.action} response did not match requested branch`);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
branch: expectedBranch,
|
|
248
|
+
ref,
|
|
249
|
+
sha: gitSha(body?.object?.sha, 'provider git ref sha')
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizePullRequest(request, body) {
|
|
254
|
+
const number = issueNumber(body?.number);
|
|
255
|
+
const head = typeof body?.head?.ref === 'string'
|
|
256
|
+
? branchName(body.head.ref, 'provider pull request head')
|
|
257
|
+
: branchName(request.context?.head, 'context.head');
|
|
258
|
+
const base = typeof body?.base?.ref === 'string'
|
|
259
|
+
? branchName(body.base.ref, 'provider pull request base')
|
|
260
|
+
: branchName(request.context?.base, 'context.base');
|
|
261
|
+
if (head !== branchName(request.context?.head, 'context.head') || base !== branchName(request.context?.base, 'context.base')) {
|
|
262
|
+
throw providerError('github_pull_request_invalid', 'GitHub pull request response did not match requested head/base');
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
pull_request_number: number,
|
|
266
|
+
html_url: body?.html_url || null,
|
|
267
|
+
head,
|
|
268
|
+
base,
|
|
269
|
+
draft: Boolean(body?.draft)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
161
273
|
function normalizedOutput(request, response, body) {
|
|
162
274
|
const common = {
|
|
163
275
|
provider: 'github',
|
|
@@ -179,6 +291,14 @@ function normalizedOutput(request, response, body) {
|
|
|
179
291
|
};
|
|
180
292
|
}
|
|
181
293
|
|
|
294
|
+
if (request.action === 'git.ref.read' || request.action === 'git.ref.create') {
|
|
295
|
+
return { ...common, ...normalizeGitRef(request, body) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (request.action === 'pull_request.create') {
|
|
299
|
+
return { ...common, ...normalizePullRequest(request, body) };
|
|
300
|
+
}
|
|
301
|
+
|
|
182
302
|
return { ...common, body: sanitizeBody(body) };
|
|
183
303
|
}
|
|
184
304
|
|
|
@@ -213,6 +333,49 @@ export function githubIssueListSelectedNumberAuthorityExtractor({ receipt, outpu
|
|
|
213
333
|
};
|
|
214
334
|
}
|
|
215
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Reviewed extractor for the exact commit SHA returned by a guarded Git ref read.
|
|
338
|
+
*/
|
|
339
|
+
export function githubGitRefShaAuthorityExtractor({ receipt, output } = {}) {
|
|
340
|
+
if (receipt?.service !== 'github' || receipt?.action !== 'git.ref.read') {
|
|
341
|
+
throw providerError(
|
|
342
|
+
'trusted_extractor_operation_mismatch',
|
|
343
|
+
'GitHub ref SHA authority extractor only accepts github:git.ref.read receipts'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (output?.provider !== 'github') {
|
|
347
|
+
throw providerError('trusted_extractor_output_invalid', 'normalized GitHub ref output is required');
|
|
348
|
+
}
|
|
349
|
+
gitSha(output.sha, 'normalized GitHub ref sha');
|
|
350
|
+
branchName(output.branch, 'normalized GitHub ref branch');
|
|
351
|
+
return {
|
|
352
|
+
extractor_id: 'github.git.ref.sha.v1',
|
|
353
|
+
selector: 'output.sha'
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Reviewed extractor for the exact PR number created by a guarded GitHub request.
|
|
359
|
+
*/
|
|
360
|
+
export function githubPullRequestCreateNumberAuthorityExtractor({ receipt, output } = {}) {
|
|
361
|
+
if (receipt?.service !== 'github' || receipt?.action !== 'pull_request.create') {
|
|
362
|
+
throw providerError(
|
|
363
|
+
'trusted_extractor_operation_mismatch',
|
|
364
|
+
'GitHub PR-number authority extractor only accepts github:pull_request.create receipts'
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (output?.provider !== 'github') {
|
|
368
|
+
throw providerError('trusted_extractor_output_invalid', 'normalized GitHub pull request output is required');
|
|
369
|
+
}
|
|
370
|
+
issueNumber(output.pull_request_number);
|
|
371
|
+
branchName(output.head, 'normalized GitHub pull request head');
|
|
372
|
+
branchName(output.base, 'normalized GitHub pull request base');
|
|
373
|
+
return {
|
|
374
|
+
extractor_id: 'github.pull-request.create.number.v1',
|
|
375
|
+
selector: 'output.pull_request_number'
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
216
379
|
export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fetch, baseUrl = 'https://api.github.com' } = {}) {
|
|
217
380
|
if (!broker) throw new Error('credential broker is required');
|
|
218
381
|
if (typeof fetchImpl !== 'function') throw new Error('fetch implementation is required');
|
|
@@ -273,6 +436,12 @@ export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fet
|
|
|
273
436
|
) {
|
|
274
437
|
return githubIssueListSelectedNumberAuthorityExtractor;
|
|
275
438
|
}
|
|
439
|
+
if (request?.service === 'github' && request?.action === 'git.ref.read' && kind === 'github.git.sha') {
|
|
440
|
+
return githubGitRefShaAuthorityExtractor;
|
|
441
|
+
}
|
|
442
|
+
if (request?.service === 'github' && request?.action === 'pull_request.create' && kind === 'github.pull_request.number') {
|
|
443
|
+
return githubPullRequestCreateNumberAuthorityExtractor;
|
|
444
|
+
}
|
|
276
445
|
return null;
|
|
277
446
|
};
|
|
278
447
|
return adapter;
|