@kosuke-ai/cli 1.0.1-next.4 → 1.0.1-next.6

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.
Files changed (86) hide show
  1. package/README.md +104 -0
  2. package/dist/commands/mcp.js +85 -0
  3. package/dist/generated/commands/index.js +43 -39
  4. package/dist/generated/commands/sessions/artifacts-get.js +20 -0
  5. package/dist/generated/commands/sessions/artifacts-list.js +19 -0
  6. package/dist/generated/tools/deploy/cancel.js +20 -0
  7. package/dist/generated/tools/deploy/messages.js +20 -0
  8. package/dist/generated/tools/deploy/run.js +52 -0
  9. package/dist/generated/tools/index.js +138 -0
  10. package/dist/generated/tools/preview/get.js +24 -0
  11. package/dist/generated/tools/preview/health.js +24 -0
  12. package/dist/generated/tools/preview/reset.js +23 -0
  13. package/dist/generated/tools/preview/share-create.js +24 -0
  14. package/dist/generated/tools/preview/share-delete.js +24 -0
  15. package/dist/generated/tools/preview/share-get.js +24 -0
  16. package/dist/generated/tools/preview/shared-artifact.js +25 -0
  17. package/dist/generated/tools/preview/shared.js +20 -0
  18. package/dist/generated/tools/projects/create.js +26 -0
  19. package/dist/generated/tools/projects/delete.js +20 -0
  20. package/dist/generated/tools/projects/env-vars-add.js +50 -0
  21. package/dist/generated/tools/projects/env-vars-remove.js +31 -0
  22. package/dist/generated/tools/projects/get.js +20 -0
  23. package/dist/generated/tools/projects/list.js +12 -0
  24. package/dist/generated/tools/projects/services-add.js +111 -0
  25. package/dist/generated/tools/projects/services-list.js +20 -0
  26. package/dist/generated/tools/projects/services-remove.js +25 -0
  27. package/dist/generated/tools/projects/services-update.js +104 -0
  28. package/dist/generated/tools/projects/settings-get.js +20 -0
  29. package/dist/generated/tools/projects/settings-import.js +208 -0
  30. package/dist/generated/tools/projects/settings-update.js +72 -0
  31. package/dist/generated/tools/projects/skills.js +32 -0
  32. package/dist/generated/tools/projects/update.js +24 -0
  33. package/dist/generated/tools/sessions/activate.js +25 -0
  34. package/dist/generated/tools/sessions/artifacts-get.js +30 -0
  35. package/dist/generated/tools/sessions/artifacts-list.js +25 -0
  36. package/dist/generated/tools/sessions/checklist-generate.js +25 -0
  37. package/dist/generated/tools/sessions/checklist-get.js +25 -0
  38. package/dist/generated/tools/sessions/comments-add.js +53 -0
  39. package/dist/generated/tools/sessions/comments-delete.js +30 -0
  40. package/dist/generated/tools/sessions/comments-list.js +25 -0
  41. package/dist/generated/tools/sessions/comments-update.js +62 -0
  42. package/dist/generated/tools/sessions/create.js +69 -0
  43. package/dist/generated/tools/sessions/delete.js +25 -0
  44. package/dist/generated/tools/sessions/diff-files.js +25 -0
  45. package/dist/generated/tools/sessions/diff-patch.js +64 -0
  46. package/dist/generated/tools/sessions/edit-prompt.js +69 -0
  47. package/dist/generated/tools/sessions/files-get.js +30 -0
  48. package/dist/generated/tools/sessions/files-list.js +25 -0
  49. package/dist/generated/tools/sessions/files-write.js +37 -0
  50. package/dist/generated/tools/sessions/get.js +25 -0
  51. package/dist/generated/tools/sessions/git-commit-message.js +25 -0
  52. package/dist/generated/tools/sessions/git-commit.js +29 -0
  53. package/dist/generated/tools/sessions/git-push.js +25 -0
  54. package/dist/generated/tools/sessions/git-state.js +25 -0
  55. package/dist/generated/tools/sessions/jobs-cancel.js +30 -0
  56. package/dist/generated/tools/sessions/jobs-get.js +30 -0
  57. package/dist/generated/tools/sessions/list.js +56 -0
  58. package/dist/generated/tools/sessions/messages.js +25 -0
  59. package/dist/generated/tools/sessions/search.js +17 -0
  60. package/dist/generated/tools/sessions/send.js +137 -0
  61. package/dist/generated/tools/sessions/update.js +63 -0
  62. package/dist/generated/tools/workspaces/access-status.js +28 -0
  63. package/dist/generated/tools/workspaces/ai-provider-get.js +20 -0
  64. package/dist/generated/tools/workspaces/ai-provider-remove.js +39 -0
  65. package/dist/generated/tools/workspaces/ai-provider-set.js +106 -0
  66. package/dist/generated/tools/workspaces/credits.js +20 -0
  67. package/dist/generated/tools/workspaces/get.js +20 -0
  68. package/dist/generated/tools/workspaces/getting-started-get.js +20 -0
  69. package/dist/generated/tools/workspaces/getting-started-update.js +27 -0
  70. package/dist/generated/tools/workspaces/list.js +12 -0
  71. package/dist/generated/tools/workspaces/members.js +24 -0
  72. package/dist/generated/tools/workspaces/sandbox-limit.js +20 -0
  73. package/dist/generated/tools/workspaces/stop-sandboxes.js +31 -0
  74. package/dist/generated/tools/workspaces/usage.js +28 -0
  75. package/dist/program.js +4 -2
  76. package/dist/runtime/mcp-attachments.js +110 -0
  77. package/dist/runtime/mcp-composite-support.js +49 -0
  78. package/dist/runtime/mcp-composite.js +309 -0
  79. package/dist/runtime/mcp-guard.js +74 -0
  80. package/dist/runtime/mcp-invoke.js +123 -0
  81. package/dist/runtime/mcp-open-pr.js +83 -0
  82. package/dist/runtime/mcp-redact.js +31 -0
  83. package/dist/runtime/mcp-server.js +219 -0
  84. package/dist/runtime/mcp-tool.js +6 -0
  85. package/openapi.json +134 -0
  86. package/package.json +2 -1
@@ -0,0 +1,110 @@
1
+ import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { RefusedArgument } from './mcp-guard.js';
5
+ /**
6
+ * Where an attachment may come from. On the CLI a person types the path; here
7
+ * the model chooses it, so a prompt that says "attach ~/.config/kosuke/credentials.json"
8
+ * would otherwise upload a token to the platform and into the transcript. Every
9
+ * path is resolved through its symlinks first, then held to one root, and a
10
+ * hidden segment (`.ssh`, `.aws`, `.config`, `.env`) is refused even inside it:
11
+ * those hold credentials far more often than screenshots.
12
+ *
13
+ * The bytes that leave are then read once, through a handle opened on the
14
+ * path that passed and checked to be a regular file, and staged as a private
15
+ * copy for the shared multipart code to pick up. Reading the original path
16
+ * again later would trust a name a concurrent writer could have re-pointed,
17
+ * and a FIFO or a device under the root would hang the server for good.
18
+ */
19
+ /** Mirrors the server's cap, which the shared attachment code enforces again. */
20
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
21
+ export function defaultAttachmentPolicy(env = process.env) {
22
+ return { root: resolve(env.KOSUKE_MCP_ATTACHMENT_ROOT ?? process.cwd()) };
23
+ }
24
+ /** The real path of a file that may be attached, or a refusal that says why. */
25
+ export function confineAttachment(path, policy) {
26
+ const given = isAbsolute(path) ? path : resolve(policy.root, path);
27
+ let root;
28
+ let real;
29
+ try {
30
+ root = realpathSync(policy.root);
31
+ }
32
+ catch {
33
+ throw new RefusedArgument(`The attachment root ${policy.root} does not exist.`);
34
+ }
35
+ try {
36
+ real = realpathSync(given);
37
+ }
38
+ catch {
39
+ throw new RefusedArgument(`Attachment ${path} does not exist or cannot be read.`);
40
+ }
41
+ const inside = relative(root, real);
42
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
43
+ throw new RefusedArgument(`Attachment ${path} resolves to ${real}, outside the attachment root ${root}. ` +
44
+ `Only files under the root can be attached; start the server with ` +
45
+ `--attachment-root or KOSUKE_MCP_ATTACHMENT_ROOT to change it.`);
46
+ }
47
+ const hidden = inside.split(sep).find((segment) => segment.startsWith('.'));
48
+ if (hidden) {
49
+ throw new RefusedArgument(`Attachment ${path} is under a hidden path (${hidden}), which this server does not ` +
50
+ `attach: such paths hold credentials more often than screenshots.`);
51
+ }
52
+ // Checked before any open: opening a FIFO for reading blocks until a writer
53
+ // arrives, and a directory or a device is not something to upload either.
54
+ if (!statSync(real).isFile()) {
55
+ throw new RefusedArgument(`Attachment ${path} is not a regular file.`);
56
+ }
57
+ return real;
58
+ }
59
+ /**
60
+ * Read each confined file once through its own handle and stage a private
61
+ * copy under a fresh 0700 directory. The handle is what is checked (a regular
62
+ * file, within the size cap) and what is read, so a path swapped after the
63
+ * check changes nothing; the copy keeps the file's name, which is what the
64
+ * platform sees, and lives only for this one request.
65
+ */
66
+ export function stageAttachments(realPaths) {
67
+ if (!realPaths.length)
68
+ return { paths: [], cleanup: () => { } };
69
+ const directory = mkdtempSync(join(tmpdir(), 'kosuke-mcp-attachment-'));
70
+ const cleanup = () => rmSync(directory, { recursive: true, force: true });
71
+ try {
72
+ const paths = realPaths.map((real, index) => {
73
+ const bytes = readOnce(real);
74
+ const slot = join(directory, String(index));
75
+ mkdirSync(slot, { mode: 0o700 });
76
+ const copy = join(slot, basename(real));
77
+ writeFileSync(copy, bytes, { mode: 0o600 });
78
+ return copy;
79
+ });
80
+ return { paths, cleanup };
81
+ }
82
+ catch (error) {
83
+ cleanup();
84
+ throw error;
85
+ }
86
+ }
87
+ function readOnce(real) {
88
+ // O_NOFOLLOW: the leaf must still be the file realpath saw, not a symlink
89
+ // planted since. O_NONBLOCK: should it have become a FIFO, open returns
90
+ // instead of waiting, and fstat refuses it below.
91
+ let fd;
92
+ try {
93
+ fd = openSync(real, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
94
+ }
95
+ catch {
96
+ throw new RefusedArgument(`Attachment ${real} cannot be opened.`);
97
+ }
98
+ try {
99
+ const stat = fstatSync(fd);
100
+ if (!stat.isFile())
101
+ throw new RefusedArgument(`Attachment ${real} is not a regular file.`);
102
+ if (stat.size > MAX_ATTACHMENT_BYTES) {
103
+ throw new RefusedArgument(`Attachment ${real} is larger than the 10 MiB attachment limit.`);
104
+ }
105
+ return readFileSync(fd);
106
+ }
107
+ finally {
108
+ closeSync(fd);
109
+ }
110
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What a composite tool is made of: the contract the server gives it, and the
3
+ * small readings of an invocation the three tools share. Kept apart from the
4
+ * tools themselves so the open-pr helpers can use them without a cycle.
5
+ */
6
+ export const COMPOSITE_TOOLSET = 'composite';
7
+ export function pick(args, keys) {
8
+ return Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((k) => [k, args[k]]));
9
+ }
10
+ export function data(invocation) {
11
+ const body = invocation.body;
12
+ const value = body?.data;
13
+ return typeof value === 'object' && value !== null ? value : {};
14
+ }
15
+ export function tail(messages, count = 3) {
16
+ if (!Array.isArray(messages))
17
+ return [];
18
+ return messages.slice(-count).map((m) => ({ role: m.role, content: messageText(m) }));
19
+ }
20
+ /** Blocks whose `content` is prose addressed to the reader; tool and plan blocks are not. */
21
+ const PROSE_BLOCKS = new Set(['text', 'clarification']);
22
+ /**
23
+ * The text of a message. A user message carries it in `content`; an
24
+ * assistant message carries `content: null` and its text in `blocks`, beside
25
+ * tool blocks. Prose blocks joined in order, so a clarification question is
26
+ * what comes back for a job that is awaiting one; `content` as the fallback;
27
+ * null when there is no text at all.
28
+ */
29
+ export function messageText(message) {
30
+ const blocks = Array.isArray(message.blocks) ? message.blocks : [];
31
+ const text = blocks
32
+ .filter((block) => PROSE_BLOCKS.has(String(block.type)) && typeof block.content === 'string')
33
+ .map((block) => block.content)
34
+ .join('\n\n')
35
+ .trim();
36
+ if (text)
37
+ return text;
38
+ return typeof message.content === 'string' && message.content.trim() ? message.content : null;
39
+ }
40
+ export function done(last, payload, warnings = last.warnings) {
41
+ return { ok: true, status: last.status, body: { data: payload }, warnings };
42
+ }
43
+ /** A step that failed: its error, named, so the model knows how far it got. */
44
+ export function failed(step, name) {
45
+ const body = typeof step.body === 'object' && step.body !== null
46
+ ? { step: name, ...step.body }
47
+ : { step: name, error: step.body ?? `HTTP ${step.status}` };
48
+ return { ...step, body };
49
+ }
@@ -0,0 +1,309 @@
1
+ import { UsageError } from './invoke.js';
2
+ import { COMPOSITE_TOOLSET, data, done, failed, messageText, pick, tail, } from './mcp-composite-support.js';
3
+ import { openPrCommand, resolveProvider } from './mcp-open-pr.js';
4
+ /**
5
+ * The hand-written tools: `ship`, `watch` and `submit`, the three arcs where a
6
+ * flat mirror of the API makes the model plumb ids by hand. Each one calls
7
+ * generated tools by name and nothing else, so it can reach no route the
8
+ * document does not describe, and it is the only part of the tool list that
9
+ * can drift from the spec.
10
+ */
11
+ /** A chat job the platform will not move on its own. */
12
+ const CHAT_SETTLED = new Set(['completed', 'failed', 'cancelled', 'awaiting_response']);
13
+ /** Likewise for a deploy; `idle` is a project that has never deployed. */
14
+ const DEPLOY_SETTLED = new Set(['deployed', 'failed', 'cancelled', 'awaiting_user', 'idle']);
15
+ /**
16
+ * Under the 60 s an MCP client waits for a call by default: a longer default
17
+ * would time out on the client side while the server kept polling, and the
18
+ * documented `done: false` would never be seen.
19
+ */
20
+ const WATCH_DEFAULT_SECONDS = 50;
21
+ const WATCH_MAX_SECONDS = 1800;
22
+ const POLL_DEFAULT_SECONDS = 5;
23
+ const id = { type: 'string' };
24
+ const ship = {
25
+ name: 'ship',
26
+ toolset: COMPOSITE_TOOLSET,
27
+ description: 'Start work: create a chat session from a prompt and start its first turn, or start an ' +
28
+ 'existing backlog session. Returns the session and the job to pass to `watch`.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ projectId: { ...id, description: 'The project.' },
33
+ content: { type: 'string', description: 'The prompt. Required unless sessionId is given.' },
34
+ sessionId: {
35
+ ...id,
36
+ description: 'An existing backlog session to start instead of creating one.',
37
+ },
38
+ title: {
39
+ type: 'string',
40
+ description: 'Session title; derived from the prompt when omitted.',
41
+ },
42
+ model: { type: 'string', description: 'Model id such as anthropic:sonnet__medium.' },
43
+ priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'] },
44
+ startBranch: {
45
+ type: 'string',
46
+ description: 'Branch to fork from; the project default otherwise.',
47
+ },
48
+ attachments: {
49
+ type: 'array',
50
+ maxItems: 10,
51
+ description: 'Files to attach to the prompt, as paths on this machine.',
52
+ items: {
53
+ type: 'object',
54
+ properties: { path: { type: 'string' } },
55
+ required: ['path'],
56
+ additionalProperties: false,
57
+ },
58
+ },
59
+ },
60
+ required: ['projectId'],
61
+ additionalProperties: false,
62
+ },
63
+ readOnly: false,
64
+ async run(args, context) {
65
+ const { projectId, sessionId, content } = args;
66
+ const warnings = [];
67
+ let result;
68
+ if (typeof sessionId === 'string' && sessionId) {
69
+ result = await context.call('sessions_activate', { projectId, sessionId });
70
+ if (!result.ok)
71
+ return failed(result, 'sessions_activate');
72
+ warnings.push(...result.warnings);
73
+ }
74
+ else {
75
+ if (typeof content !== 'string' || !content.trim()) {
76
+ throw new UsageError('content is required unless sessionId names a backlog session.');
77
+ }
78
+ result = await context.call('sessions_create', pick(args, [
79
+ 'projectId',
80
+ 'content',
81
+ 'title',
82
+ 'model',
83
+ 'priority',
84
+ 'startBranch',
85
+ 'attachments',
86
+ ]));
87
+ if (!result.ok)
88
+ return failed(result, 'sessions_create');
89
+ warnings.push(...result.warnings);
90
+ // Created with a prompt, the session is active and its first job is queued
91
+ // already; if the platform left it for us to start, start it.
92
+ if (!data(result).chatJobId) {
93
+ const session = data(result).session;
94
+ result = await context.call('sessions_activate', { projectId, sessionId: session?.id });
95
+ if (!result.ok)
96
+ return failed(result, 'sessions_activate');
97
+ warnings.push(...result.warnings);
98
+ }
99
+ }
100
+ const payload = data(result);
101
+ const session = (payload.session ?? {});
102
+ return done(result, {
103
+ sessionId: session.id,
104
+ branchName: session.branchName,
105
+ status: session.status,
106
+ chatJobId: payload.chatJobId,
107
+ jobNumber: payload.jobNumber,
108
+ watch: { projectId, sessionId: session.id, jobId: payload.chatJobId },
109
+ }, warnings);
110
+ },
111
+ };
112
+ const watch = {
113
+ name: 'watch',
114
+ toolset: COMPOSITE_TOOLSET,
115
+ description: 'Follow a chat job, or the deploy, until it settles, reporting progress on the way. ' +
116
+ 'Returns the final status and the last assistant message. Stops after timeoutSeconds ' +
117
+ '(default under the usual 60 s client limit; a longer wait needs a client that resets ' +
118
+ 'its timeout on progress) ' +
119
+ 'with done=false; call again to keep waiting.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ projectId: { ...id, description: 'The project.' },
124
+ sessionId: { ...id, description: 'The chat session; required unless deploy is true.' },
125
+ jobId: { ...id, description: 'The chat job; the latest one of the session when omitted.' },
126
+ deploy: { type: 'boolean', description: 'Watch the project deploy instead of a chat job.' },
127
+ timeoutSeconds: {
128
+ type: 'integer',
129
+ minimum: 5,
130
+ maximum: WATCH_MAX_SECONDS,
131
+ description: `How long to wait before returning done=false. Default ${WATCH_DEFAULT_SECONDS}.`,
132
+ },
133
+ pollSeconds: {
134
+ type: 'integer',
135
+ minimum: 1,
136
+ maximum: 60,
137
+ description: 'Seconds between polls.',
138
+ },
139
+ },
140
+ required: ['projectId'],
141
+ additionalProperties: false,
142
+ },
143
+ readOnly: true,
144
+ async run(args, context) {
145
+ const projectId = args.projectId;
146
+ const timeout = Number(args.timeoutSeconds ?? WATCH_DEFAULT_SECONDS) * 1000;
147
+ const interval = Number(args.pollSeconds ?? POLL_DEFAULT_SECONDS) * 1000;
148
+ const started = context.now();
149
+ const elapsed = () => Math.round((context.now() - started) / 1000);
150
+ const remaining = () => Math.max(0, timeout - (context.now() - started));
151
+ const aborted = () => context.signal?.aborted === true;
152
+ const timedOut = () => remaining() === 0 || aborted();
153
+ // Never past the deadline, and nothing more once the caller has gone.
154
+ // After the wait the deadline is tested again, so a budget shorter than
155
+ // the poll interval ends with the last observation, not one more request.
156
+ const pause = async () => {
157
+ if (aborted())
158
+ return;
159
+ await context.sleep(Math.min(interval, remaining()));
160
+ };
161
+ if (args.deploy === true) {
162
+ for (;;) {
163
+ const current = await context.call('deploy_messages', { projectId });
164
+ if (!current.ok)
165
+ return failed(current, 'deploy_messages');
166
+ const payload = data(current);
167
+ const status = String(payload.status);
168
+ const summary = {
169
+ kind: 'deploy',
170
+ status,
171
+ deployJobId: payload.deployJobId,
172
+ error: payload.error,
173
+ lastMessages: tail(payload.messages),
174
+ };
175
+ if (DEPLOY_SETTLED.has(status))
176
+ return done(current, { done: true, ...summary });
177
+ if (timedOut())
178
+ return done(current, { done: false, ...summary });
179
+ await context.progress(`deploy ${status}`, elapsed(), timeout / 1000);
180
+ await pause();
181
+ if (timedOut())
182
+ return done(current, { done: false, ...summary });
183
+ }
184
+ }
185
+ const sessionId = args.sessionId;
186
+ if (typeof sessionId !== 'string' || !sessionId) {
187
+ throw new UsageError('sessionId is required unless deploy is true.');
188
+ }
189
+ let jobId = args.jobId;
190
+ if (!jobId) {
191
+ const session = await context.call('sessions_get', { projectId, sessionId });
192
+ if (!session.ok)
193
+ return failed(session, 'sessions_get');
194
+ const latest = data(session).latestChatJob;
195
+ if (!latest?.id)
196
+ throw new UsageError('This session has no chat job to watch.');
197
+ jobId = String(latest.id);
198
+ }
199
+ for (;;) {
200
+ const current = await context.call('sessions_jobs_get', { projectId, sessionId, jobId });
201
+ if (!current.ok)
202
+ return failed(current, 'sessions_jobs_get');
203
+ const job = (data(current).job ?? {});
204
+ const status = String(job.status);
205
+ if (CHAT_SETTLED.has(status)) {
206
+ const [messages, session] = await Promise.all([
207
+ context.call('sessions_messages', { projectId, sessionId }),
208
+ context.call('sessions_get', { projectId, sessionId }),
209
+ ]);
210
+ const list = messages.ok ? data(messages).messages : undefined;
211
+ const assistant = [...(list ?? [])].reverse().find((m) => m.role === 'assistant');
212
+ const info = session.ok ? data(session) : {};
213
+ const row = (info.session ?? {});
214
+ return done(current, {
215
+ done: true,
216
+ kind: 'chat',
217
+ job,
218
+ session: {
219
+ status: row.status,
220
+ branchName: row.branchName,
221
+ pullRequestNumber: row.pullRequestNumber,
222
+ prUrl: info.prUrl,
223
+ },
224
+ lastAssistantMessage: assistant ? messageText(assistant) : null,
225
+ });
226
+ }
227
+ if (timedOut())
228
+ return done(current, { done: false, kind: 'chat', job });
229
+ await context.progress(`chat job ${status}`, elapsed(), timeout / 1000);
230
+ await pause();
231
+ if (timedOut())
232
+ return done(current, { done: false, kind: 'chat', job });
233
+ }
234
+ },
235
+ };
236
+ const submit = {
237
+ name: 'submit',
238
+ toolset: COMPOSITE_TOOLSET,
239
+ description: 'Ask the session to open its pull request, or bring the open one up to date, by sending ' +
240
+ 'the open-pr skill. Returns the job to pass to `watch`; the PR link is on the session ' +
241
+ 'once the job settles.',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ projectId: { ...id, description: 'The project.' },
246
+ sessionId: { ...id, description: 'The chat session whose branch becomes the pull request.' },
247
+ draft: { type: 'boolean', description: 'Open as a draft. Ignored once a PR exists.' },
248
+ reviewers: {
249
+ type: 'array',
250
+ items: { type: 'string' },
251
+ description: 'Reviewers to request, matched loosely against GitHub logins.',
252
+ },
253
+ evidence: {
254
+ type: 'array',
255
+ items: { type: 'string' },
256
+ description: 'Links to add under an evidence section.',
257
+ },
258
+ notes: { type: 'string', description: 'Freeform notes for the pull request body.' },
259
+ },
260
+ required: ['projectId', 'sessionId'],
261
+ additionalProperties: false,
262
+ },
263
+ readOnly: false,
264
+ async run(args, context) {
265
+ const { projectId, sessionId } = args;
266
+ const got = await context.call('sessions_get', { projectId, sessionId });
267
+ if (!got.ok)
268
+ return failed(got, 'sessions_get');
269
+ const session = (data(got).session ?? {});
270
+ if (session.status === 'backlog') {
271
+ throw new UsageError('This session is in the backlog; start it with `ship` first.');
272
+ }
273
+ // Codex reads `$open-pr`, Claude `/open-pr`, so the provider decides whether
274
+ // the message is a skill or prose, and a guess is a silent failure.
275
+ const resolved = await resolveProvider(session, projectId, context);
276
+ if ('failed' in resolved)
277
+ return resolved.failed;
278
+ const { provider, model } = resolved;
279
+ const skills = await context.call('projects_skills', { projectId, provider, sessionId });
280
+ if (!skills.ok)
281
+ return failed(skills, 'projects_skills');
282
+ const list = (data(skills).skills ?? []);
283
+ if (!list.some((skill) => skill.factoryId === 'open-pr' || skill.slug === 'open-pr')) {
284
+ throw new UsageError('The open-pr skill is not available to this session; enable it in Workspace settings.');
285
+ }
286
+ const content = openPrCommand(provider === 'openai' ? '$' : '/', args);
287
+ // When the session had no model, the one the provider was read from is
288
+ // sent along, so the turn runs on the provider the trigger was written for.
289
+ const sent = await context.call('sessions_send', {
290
+ projectId,
291
+ sessionId,
292
+ content,
293
+ ...(model ? { model } : {}),
294
+ });
295
+ if (!sent.ok)
296
+ return failed(sent, 'sessions_send');
297
+ const payload = data(sent);
298
+ return done(sent, {
299
+ sent: content,
300
+ delivery: payload.delivery,
301
+ chatJobId: payload.chatJobId,
302
+ steerId: payload.steerId,
303
+ pullRequestNumber: session.pullRequestNumber ?? null,
304
+ prUrl: data(got).prUrl ?? null,
305
+ watch: { projectId, sessionId, jobId: payload.chatJobId },
306
+ }, [...got.warnings, ...skills.warnings, ...sent.warnings]);
307
+ },
308
+ };
309
+ export const compositeTools = [ship, watch, submit];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What a tool call may not carry. Tool arguments end up in the agent's
3
+ * transcript, so a credential given as one is a credential written down.
4
+ *
5
+ * Two levels, on purpose. A field whose meaning is "this is a secret" is
6
+ * refused outright: a provider key on `workspaces ai-provider set`, or an
7
+ * environment variable marked `isSecret`. Free text is only scanned, and
8
+ * what it looks like is reported back as a warning: prose quotes credential
9
+ * shapes all the time (this feature's own ticket contains `kosuke_...`), and
10
+ * a regex must not veto a legitimate message.
11
+ */
12
+ export class RefusedArgument extends Error {
13
+ }
14
+ /** Body keys that carry a provider key on `workspaces ai-provider set`. */
15
+ const PROVIDER_KEY_FIELDS = new Set(['anthropicApiKey', 'bedrockApiKey', 'openaiApiKey']);
16
+ const SETTINGS_HINT = 'Tool arguments are visible in the agent history, so set it in Settings on the platform ' +
17
+ 'or with the CLI instead.';
18
+ export function refuseSecrets(args) {
19
+ walk(args, '', (value, path) => {
20
+ if (!isRecord(value))
21
+ return;
22
+ for (const field of Object.keys(value)) {
23
+ if (PROVIDER_KEY_FIELDS.has(field) && value[field] !== undefined && value[field] !== null) {
24
+ throw new RefusedArgument(`${join(path, field)} carries a provider key, which this tool refuses. ${SETTINGS_HINT}`);
25
+ }
26
+ }
27
+ if (value.isSecret === true && 'value' in value) {
28
+ throw new RefusedArgument(`${join(path, 'value')} is marked isSecret, which this tool refuses. ${SETTINGS_HINT}`);
29
+ }
30
+ });
31
+ }
32
+ /** Loose by design: a false positive costs a warning, a miss costs nothing more than today. */
33
+ const CREDENTIAL_SHAPES = [
34
+ { label: 'a Kosuke auth token', pattern: /\bkosuke_[A-Za-z0-9_-]{4,}/ },
35
+ { label: 'an Anthropic API key', pattern: /\bsk-ant-[A-Za-z0-9_-]{8,}/ },
36
+ { label: 'an OpenAI API key', pattern: /\bsk-(?!ant-)[A-Za-z0-9_-]{16,}/ },
37
+ { label: 'an AWS access key id', pattern: /\bAKIA[0-9A-Z]{16}\b/ },
38
+ {
39
+ label: 'a GitHub token',
40
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,})/,
41
+ },
42
+ { label: 'a private key block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
43
+ ];
44
+ /** One line per argument that looks like it quotes a credential; empty when none does. */
45
+ export function credentialWarnings(args) {
46
+ const warnings = [];
47
+ walk(args, '', (value, path) => {
48
+ if (typeof value !== 'string')
49
+ return;
50
+ for (const shape of CREDENTIAL_SHAPES) {
51
+ if (shape.pattern.test(value)) {
52
+ warnings.push(`${path || 'an argument'} looks like it contains ${shape.label}. It was sent as given; ` +
53
+ `if it is a real credential, it is now in the agent history and should be revoked.`);
54
+ }
55
+ }
56
+ });
57
+ return warnings;
58
+ }
59
+ function walk(value, path, visit) {
60
+ visit(value, path);
61
+ if (Array.isArray(value)) {
62
+ value.forEach((item, index) => walk(item, `${path}[${index}]`, visit));
63
+ }
64
+ else if (isRecord(value)) {
65
+ for (const [key, item] of Object.entries(value))
66
+ walk(item, join(path, key), visit);
67
+ }
68
+ }
69
+ function join(path, key) {
70
+ return path ? `${path}.${key}` : key;
71
+ }
72
+ function isRecord(value) {
73
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
74
+ }
@@ -0,0 +1,123 @@
1
+ import { buildRequest, execute, UsageError } from './invoke.js';
2
+ import { confineAttachment, stageAttachments } from './mcp-attachments.js';
3
+ import { credentialWarnings, refuseSecrets, RefusedArgument } from './mcp-guard.js';
4
+ import { redact } from './mcp-redact.js';
5
+ import { ATTACHMENTS_ARGUMENT, BODY_ARGUMENT } from './mcp-tool.js';
6
+ /** A tool name the server does not expose; distinct from bad arguments, since the cure differs. */
7
+ export class UnknownTool extends Error {
8
+ constructor(name) {
9
+ super(`Unknown tool "${name}". List the tools to see what this server exposes.`);
10
+ }
11
+ }
12
+ function inputFor(operation, args, policy) {
13
+ const names = new Set([...operation.query, ...(operation.body?.fields ?? []), ...(operation.multipart ?? [])].map((field) => field.name));
14
+ const fields = {};
15
+ for (const name of names)
16
+ if (args[name] !== undefined)
17
+ fields[name] = args[name];
18
+ const wholeBody = operation.body?.fields === null && args[BODY_ARGUMENT] !== undefined
19
+ ? args[BODY_ARGUMENT]
20
+ : undefined;
21
+ // Each attachment is an object so a second shape (inline bytes, for a server
22
+ // that does not share the caller's disk) can sit beside `path` later.
23
+ // Each path is confined before anything reads it.
24
+ const attachments = Array.isArray(args[ATTACHMENTS_ARGUMENT])
25
+ ? args[ATTACHMENTS_ARGUMENT].map((item) => confineAttachment(item.path, policy))
26
+ : [];
27
+ // A missing id would become an empty path segment and a 404 that blames the
28
+ // wrong thing; a composite tool handing one on is the likely source.
29
+ const pathParams = {};
30
+ for (const name of operation.pathParams) {
31
+ const value = args[name];
32
+ if (typeof value !== 'string' || !value.trim()) {
33
+ throw new UsageError(`${name} is required and cannot be empty.`);
34
+ }
35
+ pathParams[name] = value;
36
+ }
37
+ return {
38
+ pathParams,
39
+ fields,
40
+ ...(wholeBody !== undefined ? { wholeBody } : {}),
41
+ attachments,
42
+ };
43
+ }
44
+ /** Throws RefusedArgument or UsageError before any request leaves. */
45
+ export async function invokeTool(tool, args, context) {
46
+ refuseSecrets(args);
47
+ const warnings = credentialWarnings(args);
48
+ const input = inputFor(tool.operation, args, context.attachments);
49
+ // The shared request builder reads attachments by path; it gets private
50
+ // copies made from handles checked here, never the caller's paths again.
51
+ const staged = stageAttachments(input.attachments);
52
+ try {
53
+ let request;
54
+ try {
55
+ request = buildRequest(tool.operation, { ...input, attachments: staged.paths });
56
+ }
57
+ catch (error) {
58
+ if (error instanceof UsageError)
59
+ throw new UsageError(asToolUsage(tool, error.message));
60
+ throw error;
61
+ }
62
+ const outcome = await execute(context.clientFor(tool.operation.anonymous), request);
63
+ return { ok: outcome.ok, status: outcome.status, body: redact(outcome.body), warnings };
64
+ }
65
+ finally {
66
+ staged.cleanup();
67
+ }
68
+ }
69
+ /**
70
+ * The request builder speaks in flags, because the commands are its first
71
+ * caller. A model told to pass `--content` would try exactly that and be
72
+ * refused by the schema, so its messages are re-said in argument names.
73
+ */
74
+ function asToolUsage(tool, message) {
75
+ const { operation } = tool;
76
+ let text = message
77
+ .replace(`kosuke ${operation.command.join(' ')}`, tool.name)
78
+ .replace('pass it whole with --body, --body-file or on stdin', 'pass it whole as body')
79
+ .replace('pass --body, or --body-file with a path or - for stdin', 'pass body')
80
+ .replaceAll('--body', BODY_ARGUMENT);
81
+ const fields = [
82
+ ...operation.query,
83
+ ...(operation.body?.fields ?? []),
84
+ ...(operation.multipart ?? []),
85
+ ];
86
+ for (const field of fields)
87
+ text = text.replaceAll(`--${field.flag}`, field.name);
88
+ return text;
89
+ }
90
+ /**
91
+ * A success is the platform's own envelope, `{ data, meta? }`, with the
92
+ * warnings beside it when there are any; a failure is the error envelope
93
+ * plus the status, flagged so the model reads it as one.
94
+ */
95
+ export function toResult(invocation) {
96
+ const { ok, status, body, warnings } = invocation;
97
+ const extra = warnings.length ? { warnings } : {};
98
+ if (ok) {
99
+ const payload = body === undefined
100
+ ? { status, ...extra }
101
+ : typeof body === 'object' && body !== null && !Array.isArray(body)
102
+ ? { ...body, ...extra }
103
+ : { data: body, ...extra };
104
+ return { content: [text(payload)] };
105
+ }
106
+ const error = typeof body === 'object' && body !== null
107
+ ? body
108
+ : { error: body ?? `HTTP ${status}` };
109
+ return { content: [text({ status, ...error, ...extra })], isError: true };
110
+ }
111
+ export function errorResult(error) {
112
+ const message = error instanceof RefusedArgument
113
+ ? `Refused: ${error.message}`
114
+ : error instanceof UsageError
115
+ ? `Invalid arguments: ${error.message}`
116
+ : error instanceof Error
117
+ ? error.message
118
+ : String(error);
119
+ return { content: [{ type: 'text', text: message }], isError: true };
120
+ }
121
+ function text(value) {
122
+ return { type: 'text', text: JSON.stringify(value, null, 2) };
123
+ }