@gajae-code/coding-agent 0.15.3 → 0.15.5
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/CHANGELOG.md +26 -1
- package/dist/types/cli/update-cli.d.ts +25 -5
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +52 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -0
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/workflow-placeholder.d.ts +6 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/cli/session-cli.d.ts +42 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +4 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +5 -6
- package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
- package/package.json +7 -7
- package/src/cli/update-cli.ts +102 -1
- package/src/commands/sdk.ts +5 -0
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/model-profiles.ts +5 -5
- package/src/config/model-registry.ts +10 -4
- package/src/config/settings-schema.ts +53 -0
- package/src/config/settings.ts +1 -1
- package/src/coordinator-mcp/policy.ts +1 -1
- package/src/deep-interview/render-middleware.ts +6 -2
- package/src/extensibility/extensions/types.ts +5 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/launch-worktree.ts +44 -7
- package/src/gjc-runtime/ralplan-runtime.ts +20 -2
- package/src/gjc-runtime/tmux-sessions.ts +145 -48
- package/src/gjc-runtime/ultragoal-runtime.ts +5 -0
- package/src/gjc-runtime/workflow-placeholder.ts +33 -0
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/modes/components/hook-selector.ts +2 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/prompt-action-autocomplete.ts +26 -47
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
- package/src/sdk/bus/prompt-reconciliation.ts +5 -2
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/cli/session-cli.ts +149 -4
- package/src/sdk/host/host.ts +7 -0
- package/src/sdk/host/session-runtime.test.ts +821 -22
- package/src/sdk/host/session-runtime.ts +669 -67
- package/src/sdk/router/session-router.ts +69 -15
- package/src/session/agent-session.ts +302 -25
- package/src/session/internal/managed-session-storage.ts +64 -26
- package/src/session/session-manager.ts +26 -8
- package/src/tools/ask-contract.ts +54 -3
- package/src/tools/ask.ts +46 -6
- package/src/tools/bash.ts +14 -0
- package/src/tools/path-utils.ts +77 -29
- package/src/tools/tmux-self-injection-guard.ts +359 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prevent agent-authored tmux input from being delivered to the agent pane.
|
|
3
|
+
*
|
|
4
|
+
* This check runs before bash spawns anything. It is intentionally limited to
|
|
5
|
+
* tmux input verbs; inspection and control of another pane remain available.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
const INPUT_VERBS = new Set(["send-keys", "paste-buffer", "send-prefix"]);
|
|
11
|
+
const SHELL_RUNNERS = new Set(["sh", "bash", "dash", "zsh", "ksh", "busybox"]);
|
|
12
|
+
const COMMAND_WRAPPERS = new Set(["eval", "exec", "command", "builtin", "nohup", "time"]);
|
|
13
|
+
const CONTROL_WORDS = new Set(["then", "do", "else", "fi", "done", "esac"]);
|
|
14
|
+
const MAX_INDIRECTION_DEPTH = 4;
|
|
15
|
+
|
|
16
|
+
export interface TmuxSelfInjectionResult {
|
|
17
|
+
block: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TmuxPaneResolver = (options: {
|
|
22
|
+
socketArgs: string[];
|
|
23
|
+
target: string;
|
|
24
|
+
env: Record<string, string>;
|
|
25
|
+
}) => Promise<string | undefined>;
|
|
26
|
+
|
|
27
|
+
export interface TmuxSelfInjectionOptions {
|
|
28
|
+
env?: Record<string, string | undefined>;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
resolvePaneId?: TmuxPaneResolver;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Token {
|
|
34
|
+
text: string;
|
|
35
|
+
quoted: boolean;
|
|
36
|
+
commandStart: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isSeparator(ch: string): boolean {
|
|
40
|
+
return ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")" || ch === "\n";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isAssignment(text: string): boolean {
|
|
44
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Small quote-aware lexer. Unknown shell syntax is represented conservatively. */
|
|
48
|
+
function tokenize(command: string): Token[] {
|
|
49
|
+
const tokens: Token[] = [];
|
|
50
|
+
let text = "";
|
|
51
|
+
let quoted = false;
|
|
52
|
+
let quote: "'" | '"' | undefined;
|
|
53
|
+
let atCommandStart = true;
|
|
54
|
+
let wrapperPending = false;
|
|
55
|
+
let previous = "";
|
|
56
|
+
|
|
57
|
+
const flush = () => {
|
|
58
|
+
if (text.length === 0) return;
|
|
59
|
+
const token: Token = { text, quoted, commandStart: atCommandStart };
|
|
60
|
+
tokens.push(token);
|
|
61
|
+
if (atCommandStart) {
|
|
62
|
+
if (isAssignment(text) || CONTROL_WORDS.has(text) || wrapperPending) {
|
|
63
|
+
atCommandStart = true;
|
|
64
|
+
} else if (COMMAND_WRAPPERS.has(text)) {
|
|
65
|
+
atCommandStart = true;
|
|
66
|
+
wrapperPending = true;
|
|
67
|
+
} else if (text === "sudo" || text === "env") {
|
|
68
|
+
atCommandStart = true;
|
|
69
|
+
wrapperPending = true;
|
|
70
|
+
} else {
|
|
71
|
+
atCommandStart = false;
|
|
72
|
+
wrapperPending = false;
|
|
73
|
+
}
|
|
74
|
+
} else if (wrapperPending) {
|
|
75
|
+
// A wrapper's options and option arguments are not command names.
|
|
76
|
+
// The first ordinary word after them is handled by the special case
|
|
77
|
+
// below when the next token is seen.
|
|
78
|
+
atCommandStart = false;
|
|
79
|
+
}
|
|
80
|
+
text = "";
|
|
81
|
+
quoted = false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (let index = 0; index < command.length; index++) {
|
|
85
|
+
const ch = command[index];
|
|
86
|
+
if (quote !== undefined) {
|
|
87
|
+
if (ch === quote) {
|
|
88
|
+
quote = undefined;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (quote === '"' && ch === "\\" && index + 1 < command.length) {
|
|
92
|
+
text += command[++index];
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
text += ch;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === "'" || ch === '"') {
|
|
99
|
+
quote = ch;
|
|
100
|
+
quoted = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === "\\" && index + 1 < command.length) {
|
|
104
|
+
text += command[++index];
|
|
105
|
+
quoted = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (/\s/.test(ch)) {
|
|
109
|
+
flush();
|
|
110
|
+
previous = ch;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (isSeparator(ch)) {
|
|
114
|
+
flush();
|
|
115
|
+
atCommandStart = true;
|
|
116
|
+
wrapperPending = false;
|
|
117
|
+
previous = ch;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// `sudo -u user tmux` and `env FOO=bar tmux` need the wrapped command
|
|
121
|
+
// recognized. Options, option arguments, and environment assignments
|
|
122
|
+
// stay outside command position.
|
|
123
|
+
if (text.length === 0 && wrapperPending && previous && /\s/.test(previous)) {
|
|
124
|
+
if (text.length === 0 && ch === "-") {
|
|
125
|
+
// The option itself is not a command; its argument is also skipped.
|
|
126
|
+
atCommandStart = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (text.length === 0 && wrapperPending && atCommandStart) {
|
|
130
|
+
atCommandStart = true;
|
|
131
|
+
}
|
|
132
|
+
text += ch;
|
|
133
|
+
}
|
|
134
|
+
flush();
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface Invocation {
|
|
139
|
+
verb: string;
|
|
140
|
+
args: string[];
|
|
141
|
+
socketArgs: string[];
|
|
142
|
+
socket?: { flag: "-L" | "-S"; value: string };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseInvocation(tokens: Token[]): Invocation | undefined {
|
|
146
|
+
let index = 0;
|
|
147
|
+
const socketArgs: string[] = [];
|
|
148
|
+
let socket: Invocation["socket"];
|
|
149
|
+
while (index < tokens.length && !tokens[index].commandStart) {
|
|
150
|
+
const option = tokens[index].text;
|
|
151
|
+
if (option === "--") {
|
|
152
|
+
index++;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (option === "-L" || option === "-S") {
|
|
156
|
+
const value = tokens[index + 1];
|
|
157
|
+
if (!value || value.commandStart) return undefined;
|
|
158
|
+
socket = { flag: option, value: value.text };
|
|
159
|
+
socketArgs.push(option, value.text);
|
|
160
|
+
index += 2;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (option.startsWith("-L") || option.startsWith("-S")) {
|
|
164
|
+
const flag = option.slice(0, 2) as "-L" | "-S";
|
|
165
|
+
const value = option.slice(2);
|
|
166
|
+
if (!value) return undefined;
|
|
167
|
+
socket = { flag, value };
|
|
168
|
+
socketArgs.push(flag, value);
|
|
169
|
+
index++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (option.startsWith("-")) {
|
|
173
|
+
index++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
const verb = tokens[index];
|
|
179
|
+
if (!verb || verb.commandStart) return undefined;
|
|
180
|
+
const args: string[] = [];
|
|
181
|
+
for (index++; index < tokens.length && !tokens[index].commandStart; index++) args.push(tokens[index].text);
|
|
182
|
+
return { verb: verb.text, args, socketArgs, socket };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function targetFromArgs(args: string[]): string | undefined {
|
|
186
|
+
for (let index = 0; index < args.length; index++) {
|
|
187
|
+
if (args[index] === "-t") return args[index + 1];
|
|
188
|
+
if (args[index].startsWith("-t") && args[index].length > 2) return args[index].slice(2);
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function collectShellPayloads(tokens: Token[]): string[] {
|
|
194
|
+
const payloads: string[] = [];
|
|
195
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
196
|
+
const token = tokens[index];
|
|
197
|
+
if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;
|
|
198
|
+
for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
|
|
199
|
+
if (tokens[cursor].text === "-c" && tokens[cursor + 1]?.quoted) {
|
|
200
|
+
payloads.push(tokens[cursor + 1].text);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return payloads;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectShellScripts(tokens: Token[], cwd: string): string[] {
|
|
209
|
+
const scripts: string[] = [];
|
|
210
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
211
|
+
const token = tokens[index];
|
|
212
|
+
if (token.commandStart && (token.text.startsWith("./") || token.text.startsWith("/"))) {
|
|
213
|
+
scripts.push(path.resolve(cwd, token.text));
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;
|
|
217
|
+
for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
|
|
218
|
+
const argument = tokens[cursor].text;
|
|
219
|
+
if (argument === "-c") break;
|
|
220
|
+
if (argument.startsWith("-")) continue;
|
|
221
|
+
scripts.push(path.resolve(cwd, argument));
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return scripts;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface Identity {
|
|
229
|
+
socketPath: string;
|
|
230
|
+
paneId: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function currentIdentity(env: Record<string, string | undefined>): Identity | undefined {
|
|
234
|
+
const tmux = env.TMUX;
|
|
235
|
+
const paneId = env.TMUX_PANE;
|
|
236
|
+
const socketPath = tmux?.split(",")[0];
|
|
237
|
+
return socketPath && paneId ? { socketPath, paneId } : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function socketPathFor(
|
|
241
|
+
socket: NonNullable<Invocation["socket"]>,
|
|
242
|
+
env: Record<string, string | undefined>,
|
|
243
|
+
cwd: string,
|
|
244
|
+
uid: number,
|
|
245
|
+
): string {
|
|
246
|
+
if (socket.flag === "-S") return path.isAbsolute(socket.value) ? socket.value : path.resolve(cwd, socket.value);
|
|
247
|
+
return path.join(env.TMUX_TMPDIR ?? "/tmp", `tmux-${uid}`, socket.value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sameSocket(left: string, right: string): boolean {
|
|
251
|
+
if (left === right) return true;
|
|
252
|
+
try {
|
|
253
|
+
return fs.realpathSync(left) === fs.realpathSync(right);
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function assignedTmuxSocket(tokens: Token[], commandIndex: number): string | undefined {
|
|
260
|
+
for (let index = commandIndex - 1; index >= 0 && commandIndex - index <= 4; index--) {
|
|
261
|
+
const assignment = tokens[index].text.match(/^TMUX=([^,\s]+)/);
|
|
262
|
+
if (assignment) return assignment[1];
|
|
263
|
+
if (!tokens[index].commandStart) break;
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const defaultResolvePaneId: TmuxPaneResolver = async ({ socketArgs, target, env }) => {
|
|
269
|
+
const processHandle = Bun.spawn(["tmux", ...socketArgs, "display-message", "-p", "-t", target, "#{pane_id}"], {
|
|
270
|
+
env,
|
|
271
|
+
stdin: "ignore",
|
|
272
|
+
stdout: "pipe",
|
|
273
|
+
stderr: "ignore",
|
|
274
|
+
});
|
|
275
|
+
const killTimer = setTimeout(() => processHandle.kill(), 2_000);
|
|
276
|
+
try {
|
|
277
|
+
const output = await new Response(processHandle.stdout).text();
|
|
278
|
+
await processHandle.exited;
|
|
279
|
+
const paneId = output.trim().split("\n")[0];
|
|
280
|
+
return paneId?.startsWith("%") ? paneId : undefined;
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(killTimer);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export async function checkTmuxSelfInjection(
|
|
287
|
+
command: string,
|
|
288
|
+
options: TmuxSelfInjectionOptions = {},
|
|
289
|
+
depth = 0,
|
|
290
|
+
): Promise<TmuxSelfInjectionResult> {
|
|
291
|
+
const env = options.env ?? process.env;
|
|
292
|
+
const identity = currentIdentity(env);
|
|
293
|
+
if (!identity) return { block: false };
|
|
294
|
+
const cwd = options.cwd ?? env.PWD ?? ".";
|
|
295
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
296
|
+
const resolvePaneId = options.resolvePaneId ?? defaultResolvePaneId;
|
|
297
|
+
const tokens = tokenize(command);
|
|
298
|
+
|
|
299
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
300
|
+
if (!tokens[index].commandStart || (tokens[index].text !== "tmux" && !tokens[index].text.endsWith("/tmux")))
|
|
301
|
+
continue;
|
|
302
|
+
const invocation = parseInvocation(tokens.slice(index + 1));
|
|
303
|
+
if (!invocation || !INPUT_VERBS.has(invocation.verb)) continue;
|
|
304
|
+
const assignedSocket = assignedTmuxSocket(tokens, index);
|
|
305
|
+
const commandSocket = invocation.socket ? socketPathFor(invocation.socket, env, cwd, uid) : assignedSocket;
|
|
306
|
+
if (commandSocket && !sameSocket(commandSocket, identity.socketPath)) continue;
|
|
307
|
+
|
|
308
|
+
const target = targetFromArgs(invocation.args);
|
|
309
|
+
if (target === undefined) {
|
|
310
|
+
return {
|
|
311
|
+
block: true,
|
|
312
|
+
reason: `Blocked: ${invocation.verb} without a target would inject keystrokes into this agent pane (${identity.paneId}).`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (target === identity.paneId) {
|
|
316
|
+
return {
|
|
317
|
+
block: true,
|
|
318
|
+
reason: `Blocked: ${invocation.verb} targets this agent pane (${identity.paneId}); injected bytes would become a forged user turn.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!target.startsWith("%")) {
|
|
322
|
+
const resolved = await resolvePaneId({
|
|
323
|
+
socketArgs: invocation.socketArgs,
|
|
324
|
+
target,
|
|
325
|
+
env: { ...env, TMUX: `${identity.socketPath},0,0` } as Record<string, string>,
|
|
326
|
+
});
|
|
327
|
+
if (resolved !== undefined && resolved !== identity.paneId) continue;
|
|
328
|
+
if (resolved === identity.paneId || resolved === undefined) {
|
|
329
|
+
return {
|
|
330
|
+
block: true,
|
|
331
|
+
reason:
|
|
332
|
+
resolved === identity.paneId
|
|
333
|
+
? `Blocked: ${invocation.verb} target ${target} resolves to this agent pane (${identity.paneId}); injected bytes would become a forged user turn.`
|
|
334
|
+
: `Blocked: could not verify that ${invocation.verb} target ${target} is a different pane on the current tmux server; refusing fail-closed.`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (depth < MAX_INDIRECTION_DEPTH) {
|
|
341
|
+
for (const payload of collectShellPayloads(tokens)) {
|
|
342
|
+
const result = await checkTmuxSelfInjection(payload, options, depth + 1);
|
|
343
|
+
if (result.block) return result;
|
|
344
|
+
}
|
|
345
|
+
for (const scriptPath of collectShellScripts(tokens, cwd)) {
|
|
346
|
+
try {
|
|
347
|
+
const file = Bun.file(scriptPath);
|
|
348
|
+
if (!(await file.exists()) || file.size > 1_000_000) continue;
|
|
349
|
+
const script = await file.text();
|
|
350
|
+
const result = await checkTmuxSelfInjection(script, options, depth + 1);
|
|
351
|
+
if (result.block) return result;
|
|
352
|
+
} catch {
|
|
353
|
+
// An unreadable or missing script is not itself evidence that it
|
|
354
|
+
// targets this pane; let the shell report the normal file error.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { block: false };
|
|
359
|
+
}
|