@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.
- package/README.md +108 -5
- package/ROADMAP.md +30 -5
- package/docs/connected-execution-api.md +60 -0
- package/docs/connected-github.md +140 -0
- package/docs/npm-release.md +30 -10
- package/docs/product-proof.md +134 -8
- package/docs/quickstart.md +147 -0
- package/docs/release-v0.4.7.md +38 -0
- package/examples/live-github-derived-mutation.js +47 -54
- package/examples/quickstart-github-connected.mjs +77 -0
- package/examples/quickstart-github-live.mjs +84 -0
- package/examples/quickstart.mjs +101 -0
- package/examples/task-first-coding.js +238 -0
- package/examples/task-first-finance.js +223 -0
- package/examples/task-first-support.js +127 -0
- package/package.json +7 -3
- package/src/cli.js +3 -3
- package/src/connections.js +19 -4
- package/src/providers/github-coding.js +101 -0
- package/src/providers/github.js +178 -9
- package/src/task.js +45 -4
|
@@ -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.
|
|
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 = '
|
|
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
|
|
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 ${
|
|
187
|
+
console.log(`✓ GitHub ${result.account_id} disconnected and local credential removed`);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
async function agentCommand(args) {
|
package/src/connections.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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({
|
|
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
|
+
}
|
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;
|