@nullsquare/agent-authority 0.4.6 → 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.
@@ -0,0 +1,127 @@
1
+ import { AuthorityApprovalRequiredError, AuthorityDeniedError } from '../src/guard.js';
2
+ import { gmailThreadSenderAuthorityExtractor } from '../src/providers/google.js';
3
+ import { createTask } from '../src/task.js';
4
+
5
+ const threadId = 'thread:demo-91';
6
+ const calendarId = 'primary';
7
+
8
+ const task = createTask({
9
+ principal: 'user:demo',
10
+ agent: 'agent:support-demo',
11
+ request: 'Handle the customer request in this email thread and schedule the requested meeting',
12
+ permissions: {
13
+ gmail: {
14
+ allow: ['thread.read'],
15
+ deny: ['email.delete'],
16
+ constraints: { thread_id: [threadId] }
17
+ },
18
+ calendar: {
19
+ allow: ['event.create'],
20
+ deny: ['event.delete'],
21
+ constraints: { calendar_id: [calendarId] }
22
+ }
23
+ },
24
+ authority: {
25
+ originThread: { kind: 'gmail.thread', value: threadId },
26
+ calendar: { kind: 'calendar.id', value: calendarId }
27
+ },
28
+ bindings: [
29
+ { service: 'gmail', action: 'thread.read', field: 'thread_id', authority: 'originThread' },
30
+ { service: 'calendar', action: 'event.create', field: 'calendar_id', authority: 'calendar' }
31
+ ]
32
+ });
33
+
34
+ let gmailReads = 0;
35
+ let calendarMutations = 0;
36
+
37
+ console.log('Task: Handle one customer email and schedule only the meeting justified by that thread');
38
+ console.log('1. Read the exact task-authorized Gmail thread');
39
+ const read = await task.run({
40
+ service: 'gmail',
41
+ action: 'thread.read',
42
+ context: { thread_id: threadId }
43
+ }, async () => {
44
+ gmailReads += 1;
45
+ // Replace with the existing Gmail adapter/SDK call. This shape matches the
46
+ // normalized output produced by createGoogleProviderAdapter().
47
+ return {
48
+ provider: 'gmail',
49
+ sender_email: 'customer@example.com',
50
+ thread_id: threadId,
51
+ message_count: 1
52
+ };
53
+ });
54
+ console.log(` ALLOW -> sender discovered: ${read.output.sender_email}`);
55
+
56
+ const customer = task.authorityFrom(read, {
57
+ name: 'customerEmail',
58
+ kind: 'email.address',
59
+ from: 'originThread',
60
+ extractor: gmailThreadSenderAuthorityExtractor
61
+ });
62
+
63
+ task.bind({
64
+ service: 'calendar',
65
+ action: 'event.create',
66
+ field: 'attendee_email',
67
+ authority: 'customerEmail'
68
+ });
69
+ console.log(`2. Authority follows the guarded Gmail result -> ${customer.value}`);
70
+
71
+ const meetingContext = {
72
+ calendar_id: calendarId,
73
+ attendee_email: customer.value,
74
+ start_time: '2030-01-15T10:00:00Z',
75
+ end_time: '2030-01-15T10:30:00Z'
76
+ };
77
+
78
+ const meeting = await task.run({
79
+ service: 'calendar',
80
+ action: 'event.create',
81
+ context: meetingContext
82
+ }, async () => {
83
+ calendarMutations += 1;
84
+ return { provider: 'calendar', event_id: 'event:customer-demo' };
85
+ });
86
+ console.log(`3. ALLOW -> Calendar event ${meeting.output.event_id} for ${customer.value}`);
87
+
88
+ try {
89
+ await task.run({
90
+ service: 'calendar',
91
+ action: 'event.create',
92
+ context: { ...meetingContext, attendee_email: 'other@example.com' }
93
+ }, async () => {
94
+ calendarMutations += 1;
95
+ return { event_id: 'must-not-exist' };
96
+ });
97
+ throw new Error('unrelated attendee unexpectedly executed');
98
+ } catch (error) {
99
+ if (!(error instanceof AuthorityApprovalRequiredError) || error.code !== 'authority_delta_required') {
100
+ throw error;
101
+ }
102
+ console.log('4. STEP-UP -> unrelated attendee blocked before Calendar mutation');
103
+ console.log(` ${task.explain(error).summary}`);
104
+ }
105
+
106
+ if (calendarMutations !== 1) {
107
+ throw new Error(`expected exactly one Calendar mutation, got ${calendarMutations}`);
108
+ }
109
+
110
+ task.complete('customer request handled');
111
+ try {
112
+ await task.run({
113
+ service: 'calendar',
114
+ action: 'event.create',
115
+ context: meetingContext
116
+ }, async () => {
117
+ calendarMutations += 1;
118
+ return { event_id: 'must-not-run-after-completion' };
119
+ });
120
+ throw new Error('post-completion Calendar mutation unexpectedly executed');
121
+ } catch (error) {
122
+ if (!(error instanceof AuthorityDeniedError) || error.code !== 'task_lease_completed') throw error;
123
+ console.log('5. DENY -> task completion removed the meeting authority');
124
+ }
125
+
126
+ console.log(`Provider-shaped callbacks: gmail_reads=${gmailReads}, calendar_mutations=${calendarMutations}`);
127
+ console.log('PASS -> one authorized email established exactly one downstream Calendar attendee');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullsquare/agent-authority",
3
- "version": "0.4.6",
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,19 +33,21 @@
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",
39
40
  "demo:live-google": "node examples/live-google-cross-provider.js",
40
41
  "demo:mcp-upstream": "node examples/validation-mcp-upstream.js",
41
42
  "demo:guard": "node examples/direct-guard.js",
43
+ "demo:connected-github": "node examples/quickstart-github-connected.mjs",
42
44
  "benchmark:task": "node benchmarks/task-utility.mjs",
43
45
  "test": "node --test test/*.test.js",
44
46
  "test:ai-sdk": "node --test test/integrations/ai-sdk.integration.mjs",
45
47
  "test:coverage": "node --experimental-test-coverage --test test/*.test.js",
46
- "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 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",
47
49
  "check:package": "npm pack --dry-run",
48
- "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"
49
51
  },
50
52
  "dependencies": {
51
53
  "@modelcontextprotocol/client": "^2.0.0",
@@ -73,11 +75,13 @@
73
75
  "./mcp-gateway": "./src/mcp-gateway.js",
74
76
  "./mcp-remote": "./src/mcp-remote.js",
75
77
  "./mcp-server": "./src/mcp-server.js",
78
+ "./runtime-env": "./src/runtime-env.js",
76
79
  "./sdk": "./src/sdk.js",
77
80
  "./storage": "./src/storage.js",
78
81
  "./task": "./src/task.js",
79
82
  "./task-lease": "./src/task-lease.js",
80
83
  "./providers/github": "./src/providers/github.js",
84
+ "./providers/github-coding": "./src/providers/github-coding.js",
81
85
  "./providers/google": "./src/providers/google.js"
82
86
  },
83
87
  "files": ["src", "docs", "examples", "benchmarks", "README.md", "LICENSE", "SECURITY.md", "ROADMAP.md", "CONTRIBUTING.md"],
package/src/cli.js CHANGED
@@ -6,7 +6,7 @@ import { createAgentToken, parseTtl } from './agent-auth.js';
6
6
  import { createRuntimeEnvironment } from './runtime-env.js';
7
7
  import { authorityHome, ensureAuthorityHome, loadConfig, saveConfig } from './storage.js';
8
8
 
9
- const VERSION = '0.3.0';
9
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
10
 
11
11
  function fail(message, code = 1) {
12
12
  console.error(`error: ${message}`);
@@ -144,7 +144,7 @@ async function connectionsCommand(args) {
144
144
  }
145
145
 
146
146
  async function connectGitHub(args) {
147
- if (!has(args, '--token-stdin')) throw new Error('GitHub currently requires --token-stdin; browser OAuth/PKCE is the next provider-onboarding milestone');
147
+ if (!has(args, '--token-stdin')) throw new Error('GitHub local onboarding currently requires --token-stdin');
148
148
  const home = homeFrom(args);
149
149
  const env = createRuntimeEnvironment({ home });
150
150
  const token = await readStdinSecret();
@@ -184,7 +184,7 @@ async function disconnectGitHub(args) {
184
184
  const accountId = flag(args, '--account', 'default');
185
185
  const result = env.broker.disconnect({ principal_id: env.config.principal_id, service: 'github', account_id: accountId });
186
186
  if (!result) throw new Error(`GitHub account ${accountId} is not connected`);
187
- console.log(`✓ GitHub ${accountId} disconnected and local credential removed`);
187
+ console.log(`✓ GitHub ${result.account_id} disconnected and local credential removed`);
188
188
  }
189
189
 
190
190
  async function agentCommand(args) {
@@ -116,8 +116,19 @@ export class CredentialBroker {
116
116
  return connection;
117
117
  }
118
118
 
119
+ resolveConnection({ principal_id, service, account_id = 'default' }) {
120
+ const exact = this.connections.get({ principal_id, service, account_id });
121
+ if (exact) return exact;
122
+ if (account_id !== 'default') return null;
123
+
124
+ const candidates = this.connections.list(principal_id)
125
+ .filter((connection) => connection.service === service && connection.status === 'active');
126
+
127
+ return candidates.length === 1 ? candidates[0] : null;
128
+ }
129
+
119
130
  getConnection({ principal_id, service, account_id = 'default' }) {
120
- return this.connections.get({ principal_id, service, account_id });
131
+ return this.resolveConnection({ principal_id, service, account_id });
121
132
  }
122
133
 
123
134
  listConnections(principal_id) {
@@ -125,7 +136,7 @@ export class CredentialBroker {
125
136
  }
126
137
 
127
138
  resolveInternal({ principal_id, service, account_id = 'default' }) {
128
- const connection = this.connections.get({ principal_id, service, account_id });
139
+ const connection = this.resolveConnection({ principal_id, service, account_id });
129
140
  if (!connection || connection.status !== 'active') {
130
141
  const error = new Error(`no active ${service} connection for this principal`);
131
142
  error.code = 'connection_required';
@@ -139,10 +150,14 @@ export class CredentialBroker {
139
150
  }
140
151
 
141
152
  disconnect({ principal_id, service, account_id = 'default' }) {
142
- const current = this.connections.get({ principal_id, service, account_id });
153
+ const current = this.resolveConnection({ principal_id, service, account_id });
143
154
  if (!current) return null;
144
155
  this.secrets.delete(current.credential_ref);
145
- const connection = this.connections.disconnect({ principal_id, service, account_id });
156
+ const connection = this.connections.disconnect({
157
+ principal_id,
158
+ service,
159
+ account_id: current.account_id
160
+ });
146
161
  if (!connection) return null;
147
162
  const { credential_ref, ...safe } = connection;
148
163
  return safe;
@@ -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
+ }
@@ -1,6 +1,12 @@
1
1
  import { brokeredProviderAdapter } from '../connections.js';
2
2
 
3
- const MUTATING_ACTIONS = new Set(['issue.create', 'issue.comment', 'pull_request.create', 'repo.contents.write']);
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 String(path)
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 = required(context.path, 'context.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: required(context.head, 'context.head'),
96
- base: required(context.base, 'context.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 = required(context.path, 'context.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 || undefined
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;