pi-openappa 0.2.0 → 0.3.1
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 +64 -6
- package/extensions/index.ts +218 -73
- package/package.json +8 -1
- package/src/gate.ts +62 -7
- package/templates/appa.toml +1068 -0
- package/traces/coding-loop.appa +31 -0
- package/traces/memory-poison.appa +24 -0
- package/traces/push-day.appa +21 -0
- package/traces/research-then-code.appa +53 -0
- package/traces/secret-ops.appa +23 -0
- package/justfile +0 -39
- package/test/adapter.test.ts +0 -207
- package/test/auto-install.test.ts +0 -85
- package/test/extension.test.ts +0 -667
- package/test/fixtures/mock-appa.mjs +0 -104
- package/test/gate.test.ts +0 -131
- package/tsconfig.json +0 -16
package/test/extension.test.ts
DELETED
|
@@ -1,667 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { after, beforeEach, describe, it } from "node:test";
|
|
3
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync, copyFileSync, chmodSync } from "node:fs";
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join, dirname } from "node:path";
|
|
7
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
-
|
|
9
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const MOCK_BIN = join(here, "fixtures", "mock-appa.mjs");
|
|
11
|
-
const SESSION = "22222222-2222-2222-2222-222222222222";
|
|
12
|
-
|
|
13
|
-
/** Environment keys this suite mutates; restored after every test. */
|
|
14
|
-
const KEYS = ["APPA_GATE", "APPA_HOOK_BIN", "MOCK_MODE", "MOCK_RECORD", "APPA_HOOK_TIMEOUT_MS", "APPA_CONFIG", "XDG_CONFIG_HOME", "APPA_INSTALL_CMD", "APPA_INSTALL_TIMEOUT_MS", "PATH", "HOME", "APPA_RUNTIME_URL"];
|
|
15
|
-
const saved: Record<string, string | undefined> = {};
|
|
16
|
-
for (const key of KEYS) saved[key] = process.env[key];
|
|
17
|
-
|
|
18
|
-
const workDirs: string[] = [];
|
|
19
|
-
function workDir(): string {
|
|
20
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-openappa-test-"));
|
|
21
|
-
workDirs.push(dir);
|
|
22
|
-
return dir;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
after(() => {
|
|
26
|
-
for (const dir of workDirs) rmSync(dir, { recursive: true, force: true });
|
|
27
|
-
for (const key of KEYS) {
|
|
28
|
-
const value = saved[key];
|
|
29
|
-
if (value === undefined) delete process.env[key];
|
|
30
|
-
else process.env[key] = value;
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
type Handler = (event: any, ctx: any) => Promise<any>;
|
|
35
|
-
|
|
36
|
-
interface Harness {
|
|
37
|
-
handlers: Map<string, Handler>;
|
|
38
|
-
commands: Map<string, { handler: Handler }>;
|
|
39
|
-
ctx: unknown;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function loadExtension(cwd?: string): Promise<Harness> {
|
|
43
|
-
const mod = await import(pathToFileURL(join(here, "..", "extensions", "index.ts")).href);
|
|
44
|
-
const handlers = new Map<string, Handler>();
|
|
45
|
-
const commands = new Map<string, { handler: Handler }>();
|
|
46
|
-
const fakePi = {
|
|
47
|
-
on: (name: string, handler: Handler) => handlers.set(name, handler),
|
|
48
|
-
registerCommand: (name: string, options: { handler: Handler }) =>
|
|
49
|
-
commands.set(name, options),
|
|
50
|
-
};
|
|
51
|
-
mod.default(fakePi);
|
|
52
|
-
const ctx = {
|
|
53
|
-
cwd: cwd ?? "/home/aemonge/projects/pi-openappa",
|
|
54
|
-
hasUI: false,
|
|
55
|
-
sessionManager: { getSessionId: () => SESSION },
|
|
56
|
-
ui: { notify: () => {} },
|
|
57
|
-
};
|
|
58
|
-
return { handlers, commands, ctx };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function startSession(harness: Harness, reason = "startup"): Promise<void> {
|
|
62
|
-
const handler = harness.handlers.get("session_start");
|
|
63
|
-
assert.ok(handler, "session_start handler registered");
|
|
64
|
-
await handler({ type: "session_start", reason }, harness.ctx);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function recordedLines(dir: string): string[] {
|
|
68
|
-
const file = join(dir, "record.jsonl");
|
|
69
|
-
if (!existsSync(file)) return [];
|
|
70
|
-
return readFileSync(file, "utf8")
|
|
71
|
-
.split("\n")
|
|
72
|
-
.filter((line) => line !== "");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function withMock(dir: string, mode: string): void {
|
|
76
|
-
process.env.APPA_GATE = "1";
|
|
77
|
-
process.env.APPA_HOOK_BIN = MOCK_BIN;
|
|
78
|
-
process.env.MOCK_MODE = mode;
|
|
79
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** PATH with `appa` absent: extra dirs first, then only node/curl/sh system dirs. */
|
|
83
|
-
function hermeticPath(...extraDirs: string[]): string {
|
|
84
|
-
return [...extraDirs, dirname(process.execPath), "/usr/bin", "/bin"].join(":");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Give the fake ctx a recording UI; returns [kind, message] pairs. */
|
|
88
|
-
function enableUiSpy(harness: Harness): Array<[string, string]> {
|
|
89
|
-
const notices: Array<[string, string]> = [];
|
|
90
|
-
const ctx = harness.ctx as { hasUI?: boolean; ui?: { notify: (m: string, k: string) => void } };
|
|
91
|
-
ctx.hasUI = true;
|
|
92
|
-
ctx.ui = { notify: (message, kind) => notices.push([kind, message]) };
|
|
93
|
-
return notices;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const bashCall = { type: "tool_call", toolCallId: "call-1", toolName: "bash", input: { command: "ls", description: "list" } };
|
|
97
|
-
const bashResult = {
|
|
98
|
-
type: "tool_result",
|
|
99
|
-
toolCallId: "call-1",
|
|
100
|
-
toolName: "bash",
|
|
101
|
-
input: { command: "ls", description: "list" },
|
|
102
|
-
content: [{ type: "text", text: "file1 file2" }],
|
|
103
|
-
details: { stdout: "file1 file2", exitcode: 0 },
|
|
104
|
-
isError: false,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
beforeEach(() => {
|
|
108
|
-
// Isolate gate resolution from real machine state: clean config dir and
|
|
109
|
-
// HOME per test (no off markers, no APPA default policy), no gate env.
|
|
110
|
-
// Tests that want to opt out set APPA_GATE=0 or a marker themselves.
|
|
111
|
-
process.env.XDG_CONFIG_HOME = join(workDir(), "xdg");
|
|
112
|
-
process.env.HOME = join(workDir(), "home");
|
|
113
|
-
delete process.env.APPA_GATE;
|
|
114
|
-
delete process.env.APPA_CONFIG;
|
|
115
|
-
delete process.env.APPA_RUNTIME_URL;
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe("opted-out session (APPA_GATE=0): extension is inert", () => {
|
|
119
|
-
beforeEach(() => {
|
|
120
|
-
process.env.APPA_GATE = "0";
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("never invokes the hook and passes everything through", async () => {
|
|
124
|
-
const dir = workDir();
|
|
125
|
-
process.env.APPA_HOOK_BIN = MOCK_BIN;
|
|
126
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
127
|
-
const harness = await loadExtension();
|
|
128
|
-
await startSession(harness);
|
|
129
|
-
const prompt = await harness.handlers.get("before_agent_start")!(
|
|
130
|
-
{ type: "before_agent_start", prompt: "hello", systemPrompt: "", systemPromptOptions: {} },
|
|
131
|
-
harness.ctx,
|
|
132
|
-
);
|
|
133
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
134
|
-
const result = await harness.handlers.get("tool_result")!(bashResult, harness.ctx);
|
|
135
|
-
const turnEnd = await harness.handlers.get("turn_end")!({ type: "turn_end" }, harness.ctx);
|
|
136
|
-
assert.equal(prompt, undefined);
|
|
137
|
-
assert.equal(call, undefined);
|
|
138
|
-
assert.equal(result, undefined);
|
|
139
|
-
assert.equal(turnEnd, undefined);
|
|
140
|
-
assert.deepEqual(recordedLines(dir), []);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
describe("gated session through the scripted mock", () => {
|
|
145
|
-
it("allows a released call and passes its result", async () => {
|
|
146
|
-
const dir = workDir();
|
|
147
|
-
withMock(dir, "allow");
|
|
148
|
-
const harness = await loadExtension();
|
|
149
|
-
await startSession(harness);
|
|
150
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
151
|
-
assert.equal(call, undefined);
|
|
152
|
-
const result = await harness.handlers.get("tool_result")!(bashResult, harness.ctx);
|
|
153
|
-
assert.equal(result, undefined);
|
|
154
|
-
const events = recordedLines(dir).map((line) => JSON.parse(line));
|
|
155
|
-
assert.deepEqual(
|
|
156
|
-
events.map((e) => e.hook_event_name),
|
|
157
|
-
["SessionStart", "PreToolUse", "PostToolUse"],
|
|
158
|
-
);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("denies with the runtime's reason on exit 2", async () => {
|
|
162
|
-
const dir = workDir();
|
|
163
|
-
withMock(dir, "deny-stderr");
|
|
164
|
-
const harness = await loadExtension();
|
|
165
|
-
await startSession(harness);
|
|
166
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
167
|
-
assert.deepEqual(call, {
|
|
168
|
-
block: true,
|
|
169
|
-
reason: "appa: not declared in this policy",
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("denies with a structured permission reason", async () => {
|
|
174
|
-
const dir = workDir();
|
|
175
|
-
withMock(dir, "deny-permission");
|
|
176
|
-
const harness = await loadExtension();
|
|
177
|
-
await startSession(harness);
|
|
178
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
179
|
-
assert.deepEqual(call, {
|
|
180
|
-
block: true,
|
|
181
|
-
reason: "appa: appa: this command may not run here",
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("replaces a withheld result with the runtime's output", async () => {
|
|
186
|
-
const dir = workDir();
|
|
187
|
-
withMock(dir, "replace-result");
|
|
188
|
-
const harness = await loadExtension();
|
|
189
|
-
await startSession(harness);
|
|
190
|
-
await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
191
|
-
const result = await harness.handlers.get("tool_result")!(bashResult, harness.ctx);
|
|
192
|
-
assert.deepEqual(result, {
|
|
193
|
-
content: [{ type: "text", text: "[appa] the tool result was withheld by policy" }],
|
|
194
|
-
isError: false,
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("withholds a result as an error when the runtime blocks it", async () => {
|
|
199
|
-
const dir = workDir();
|
|
200
|
-
withMock(dir, "deny-result");
|
|
201
|
-
const harness = await loadExtension();
|
|
202
|
-
await startSession(harness);
|
|
203
|
-
await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
204
|
-
const result = await harness.handlers.get("tool_result")!(bashResult, harness.ctx);
|
|
205
|
-
assert.equal(result.isError, true);
|
|
206
|
-
assert.ok(result.content[0].text.includes("result may not cross"));
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("fails closed when the hook binary cannot start", async () => {
|
|
210
|
-
const dir = workDir();
|
|
211
|
-
withMock(dir, "allow");
|
|
212
|
-
// A named config keeps this fail-closed: with none, a failed start with
|
|
213
|
-
// nothing answering downgrades the session to unprotected instead.
|
|
214
|
-
process.env.APPA_CONFIG = "/tmp/policy.toml";
|
|
215
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
|
|
216
|
-
process.env.APPA_HOOK_BIN = join(dir, "does-not-exist");
|
|
217
|
-
const harness = await loadExtension();
|
|
218
|
-
await startSession(harness);
|
|
219
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
220
|
-
assert.equal(call.block, true);
|
|
221
|
-
assert.ok(call.reason.includes("failed to start"));
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("fails closed on hook timeout", async () => {
|
|
225
|
-
const dir = workDir();
|
|
226
|
-
withMock(dir, "sleep");
|
|
227
|
-
process.env.APPA_CONFIG = "/tmp/policy.toml"; // no no-policy downgrade
|
|
228
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
|
|
229
|
-
process.env.APPA_HOOK_TIMEOUT_MS = "150";
|
|
230
|
-
const harness = await loadExtension();
|
|
231
|
-
await startSession(harness);
|
|
232
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
233
|
-
assert.equal(call.block, true);
|
|
234
|
-
assert.ok(call.reason.includes("timed out"));
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("treats a subagent spawn as a plain tool call", async () => {
|
|
238
|
-
const dir = workDir();
|
|
239
|
-
withMock(dir, "allow");
|
|
240
|
-
const harness = await loadExtension();
|
|
241
|
-
await startSession(harness);
|
|
242
|
-
const spawn = await harness.handlers.get("tool_call")!(
|
|
243
|
-
{
|
|
244
|
-
type: "tool_call",
|
|
245
|
-
toolCallId: "call-2",
|
|
246
|
-
toolName: "subagent",
|
|
247
|
-
input: { agent: "scout", prompt: "look around" },
|
|
248
|
-
},
|
|
249
|
-
harness.ctx,
|
|
250
|
-
);
|
|
251
|
-
assert.equal(spawn, undefined);
|
|
252
|
-
const events = recordedLines(dir).map((line) => JSON.parse(line));
|
|
253
|
-
const pre = events.find((e) => e.hook_event_name === "PreToolUse");
|
|
254
|
-
assert.equal(pre.tool_name, "subagent");
|
|
255
|
-
assert.deepEqual(pre.tool_input, { agent: "scout", prompt: "look around" });
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it("echoes the exact tool_input bytes between Pre and PostToolUse", async () => {
|
|
259
|
-
const dir = workDir();
|
|
260
|
-
withMock(dir, "allow");
|
|
261
|
-
const harness = await loadExtension();
|
|
262
|
-
await startSession(harness);
|
|
263
|
-
await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
264
|
-
// The executed input mutates afterwards (another handler rewrote it);
|
|
265
|
-
// the result event sees different bytes than what was checked.
|
|
266
|
-
const mutatedResult = {
|
|
267
|
-
...bashResult,
|
|
268
|
-
input: { command: "ls -la", description: "list" },
|
|
269
|
-
};
|
|
270
|
-
await harness.handlers.get("tool_result")!(mutatedResult, harness.ctx);
|
|
271
|
-
const lines = recordedLines(dir).map((line) => JSON.parse(line));
|
|
272
|
-
const pre = lines.find((e) => e.hook_event_name === "PreToolUse");
|
|
273
|
-
const post = lines.find((e) => e.hook_event_name === "PostToolUse");
|
|
274
|
-
assert.equal(
|
|
275
|
-
JSON.stringify(pre.tool_input),
|
|
276
|
-
JSON.stringify({ command: "ls", description: "list" }),
|
|
277
|
-
);
|
|
278
|
-
assert.equal(JSON.stringify(post.tool_input), JSON.stringify(pre.tool_input));
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("reports turn end with --turn-end semantics (non-blocking)", async () => {
|
|
282
|
-
const dir = workDir();
|
|
283
|
-
withMock(dir, "crash");
|
|
284
|
-
const harness = await loadExtension();
|
|
285
|
-
await startSession(harness);
|
|
286
|
-
const outcome = await harness.handlers.get("turn_end")!({ type: "turn_end" }, harness.ctx);
|
|
287
|
-
assert.equal(outcome, undefined);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("sends the prompt as the turn boundary and stop at turn end", async () => {
|
|
291
|
-
const dir = workDir();
|
|
292
|
-
withMock(dir, "allow");
|
|
293
|
-
const harness = await loadExtension();
|
|
294
|
-
await startSession(harness);
|
|
295
|
-
await harness.handlers.get("before_agent_start")!(
|
|
296
|
-
{ type: "before_agent_start", prompt: "list the files", systemPrompt: "", systemPromptOptions: {} },
|
|
297
|
-
harness.ctx,
|
|
298
|
-
);
|
|
299
|
-
await harness.handlers.get("turn_end")!({ type: "turn_end" }, harness.ctx);
|
|
300
|
-
const events = recordedLines(dir).map((line) => JSON.parse(line));
|
|
301
|
-
const prompt = events.find((e) => e.hook_event_name === "UserPromptSubmit");
|
|
302
|
-
const stop = events.find((e) => e.hook_event_name === "Stop");
|
|
303
|
-
assert.equal(prompt.prompt, "list the files");
|
|
304
|
-
assert.ok(stop);
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
function recordedArgv(dir: string): string[][] {
|
|
309
|
-
const file = `${join(dir, "record.jsonl")}.argv`;
|
|
310
|
-
if (!existsSync(file)) return [];
|
|
311
|
-
return readFileSync(file, "utf8")
|
|
312
|
-
.split("\n")
|
|
313
|
-
.filter((line) => line !== "")
|
|
314
|
-
.map((line) => JSON.parse(line) as string[]);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
describe("auto-start on gated session start", () => {
|
|
318
|
-
it("passes --ensure-runtime and --config from APPA_CONFIG", async () => {
|
|
319
|
-
const dir = workDir();
|
|
320
|
-
withMock(dir, "allow");
|
|
321
|
-
process.env.APPA_CONFIG = "/tmp/playground/appa.toml";
|
|
322
|
-
const harness = await loadExtension();
|
|
323
|
-
await startSession(harness);
|
|
324
|
-
const first = recordedArgv(dir)[0];
|
|
325
|
-
assert.ok(first?.includes("--ensure-runtime"), `argv: ${JSON.stringify(first)}`);
|
|
326
|
-
const configIndex = first?.indexOf("--config");
|
|
327
|
-
assert.notEqual(configIndex, -1);
|
|
328
|
-
assert.equal(first?.[Number(configIndex) + 1], "/tmp/playground/appa.toml");
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it("omits --config when APPA_CONFIG is unset", async () => {
|
|
332
|
-
const dir = workDir();
|
|
333
|
-
withMock(dir, "allow");
|
|
334
|
-
delete process.env.APPA_CONFIG;
|
|
335
|
-
const harness = await loadExtension();
|
|
336
|
-
await startSession(harness);
|
|
337
|
-
const first = recordedArgv(dir)[0];
|
|
338
|
-
assert.ok(first?.includes("--ensure-runtime"));
|
|
339
|
-
assert.ok(!first?.includes("--config"));
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("starts nothing when opted out (APPA_GATE=0)", async () => {
|
|
343
|
-
const dir = workDir();
|
|
344
|
-
withMock(dir, "allow");
|
|
345
|
-
process.env.APPA_GATE = "0";
|
|
346
|
-
delete process.env.APPA_CONFIG;
|
|
347
|
-
const harness = await loadExtension();
|
|
348
|
-
await startSession(harness);
|
|
349
|
-
assert.deepEqual(recordedArgv(dir), []);
|
|
350
|
-
assert.deepEqual(recordedLines(dir), []);
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
describe("auto-install on gated session start (default-on)", () => {
|
|
355
|
-
it("installs the default appa and retries ensure-runtime", async () => {
|
|
356
|
-
const dir = workDir();
|
|
357
|
-
const binDir = join(dir, "bin");
|
|
358
|
-
mkdirSync(binDir);
|
|
359
|
-
// No APPA_GATE: protection is on by default now.
|
|
360
|
-
delete process.env.APPA_HOOK_BIN;
|
|
361
|
-
delete process.env.APPA_CONFIG;
|
|
362
|
-
process.env.MOCK_MODE = "allow";
|
|
363
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
364
|
-
const installed = join(binDir, "appa");
|
|
365
|
-
process.env.APPA_INSTALL_CMD = `cp ${MOCK_BIN} ${installed} && chmod +x ${installed}`;
|
|
366
|
-
process.env.PATH = hermeticPath(binDir);
|
|
367
|
-
const harness = await loadExtension();
|
|
368
|
-
const notices = enableUiSpy(harness);
|
|
369
|
-
await startSession(harness);
|
|
370
|
-
assert.ok(existsSync(installed), "installer ran and placed appa");
|
|
371
|
-
const first = recordedArgv(dir)[0];
|
|
372
|
-
assert.ok(first?.includes("--ensure-runtime"), `argv: ${JSON.stringify(first)}`);
|
|
373
|
-
assert.ok(!first?.includes("--config"));
|
|
374
|
-
assert.ok(notices.some(([kind]) => kind === "info"), "install notice shown");
|
|
375
|
-
assert.deepEqual(
|
|
376
|
-
notices.filter(([kind]) => kind === "warning"),
|
|
377
|
-
[],
|
|
378
|
-
`no warnings on success: ${JSON.stringify(notices)}`,
|
|
379
|
-
);
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it("reports a failed install once and never retries the hook", async () => {
|
|
383
|
-
const dir = workDir();
|
|
384
|
-
process.env.APPA_GATE = "1";
|
|
385
|
-
delete process.env.APPA_HOOK_BIN;
|
|
386
|
-
delete process.env.APPA_CONFIG;
|
|
387
|
-
process.env.MOCK_MODE = "allow";
|
|
388
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
389
|
-
process.env.APPA_INSTALL_CMD = "echo boom >&2; exit 3";
|
|
390
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
|
|
391
|
-
process.env.PATH = hermeticPath(join(dir, "bin"));
|
|
392
|
-
const harness = await loadExtension();
|
|
393
|
-
const notices = enableUiSpy(harness);
|
|
394
|
-
await startSession(harness);
|
|
395
|
-
assert.deepEqual(recordedArgv(dir), []);
|
|
396
|
-
assert.deepEqual(recordedLines(dir), []);
|
|
397
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
398
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
399
|
-
assert.ok(warnings[0]?.[1].includes("auto-install failed"));
|
|
400
|
-
assert.ok(warnings[0]?.[1].includes("boom"));
|
|
401
|
-
assert.ok(warnings[0]?.[1].includes("blocked"));
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it("warns once when the install succeeds but appa is still missing", async () => {
|
|
405
|
-
const dir = workDir();
|
|
406
|
-
process.env.APPA_GATE = "1";
|
|
407
|
-
delete process.env.APPA_HOOK_BIN;
|
|
408
|
-
delete process.env.APPA_CONFIG;
|
|
409
|
-
process.env.MOCK_MODE = "allow";
|
|
410
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
411
|
-
process.env.APPA_INSTALL_CMD = "true";
|
|
412
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
|
|
413
|
-
process.env.PATH = hermeticPath(join(dir, "bin"));
|
|
414
|
-
const harness = await loadExtension();
|
|
415
|
-
const notices = enableUiSpy(harness);
|
|
416
|
-
await startSession(harness);
|
|
417
|
-
assert.deepEqual(recordedArgv(dir), []);
|
|
418
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
419
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
420
|
-
assert.ok(warnings[0]?.[1].includes("still not on PATH"));
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it("never auto-installs a custom APPA_HOOK_BIN", async () => {
|
|
424
|
-
const dir = workDir();
|
|
425
|
-
process.env.APPA_GATE = "1";
|
|
426
|
-
process.env.APPA_HOOK_BIN = join(dir, "missing-bin");
|
|
427
|
-
delete process.env.APPA_CONFIG;
|
|
428
|
-
process.env.MOCK_MODE = "allow";
|
|
429
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
430
|
-
const marker = join(dir, "install-ran");
|
|
431
|
-
process.env.APPA_INSTALL_CMD = `touch ${marker}`;
|
|
432
|
-
process.env.PATH = hermeticPath();
|
|
433
|
-
const harness = await loadExtension();
|
|
434
|
-
const notices = enableUiSpy(harness);
|
|
435
|
-
await startSession(harness);
|
|
436
|
-
assert.ok(!existsSync(marker), "installer must not run for custom binaries");
|
|
437
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
438
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
439
|
-
assert.ok(warnings[0]?.[1].includes("did not answer"), "generic fail-closed warning");
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
describe("project-scoped protection (.pi/openappa)", () => {
|
|
444
|
-
it("gates the project and passes the marker's policy as --config", async () => {
|
|
445
|
-
const dir = workDir();
|
|
446
|
-
withMock(dir, "allow");
|
|
447
|
-
delete process.env.APPA_GATE;
|
|
448
|
-
delete process.env.APPA_CONFIG;
|
|
449
|
-
const proj = join(dir, "proj");
|
|
450
|
-
mkdirSync(join(proj, ".pi"), { recursive: true });
|
|
451
|
-
writeFileSync(join(proj, ".pi", "openappa"), "appa.toml\n");
|
|
452
|
-
const harness = await loadExtension(proj);
|
|
453
|
-
await startSession(harness);
|
|
454
|
-
const first = recordedArgv(dir)[0];
|
|
455
|
-
assert.ok(first?.includes("--ensure-runtime"), `argv: ${JSON.stringify(first)}`);
|
|
456
|
-
const index = first?.indexOf("--config");
|
|
457
|
-
assert.notEqual(index, -1);
|
|
458
|
-
assert.equal(first?.[Number(index) + 1], join(proj, "appa.toml"));
|
|
459
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
460
|
-
assert.equal(call, undefined);
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
it("empty marker content passes no --config", async () => {
|
|
464
|
-
const dir = workDir();
|
|
465
|
-
withMock(dir, "allow");
|
|
466
|
-
delete process.env.APPA_GATE;
|
|
467
|
-
delete process.env.APPA_CONFIG;
|
|
468
|
-
const proj = join(dir, "proj2");
|
|
469
|
-
mkdirSync(join(proj, ".pi"), { recursive: true });
|
|
470
|
-
writeFileSync(join(proj, ".pi", "openappa"), "");
|
|
471
|
-
const harness = await loadExtension(proj);
|
|
472
|
-
await startSession(harness);
|
|
473
|
-
const first = recordedArgv(dir)[0];
|
|
474
|
-
assert.ok(first?.includes("--ensure-runtime"));
|
|
475
|
-
assert.ok(!first?.includes("--config"));
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it("explicit APPA_CONFIG wins over marker content", async () => {
|
|
479
|
-
const dir = workDir();
|
|
480
|
-
withMock(dir, "allow");
|
|
481
|
-
delete process.env.APPA_GATE;
|
|
482
|
-
process.env.APPA_CONFIG = "/tmp/env-policy.toml";
|
|
483
|
-
const proj = join(dir, "proj3");
|
|
484
|
-
mkdirSync(join(proj, ".pi"), { recursive: true });
|
|
485
|
-
writeFileSync(join(proj, ".pi", "openappa"), "appa.toml\n");
|
|
486
|
-
const harness = await loadExtension(proj);
|
|
487
|
-
await startSession(harness);
|
|
488
|
-
const first = recordedArgv(dir)[0];
|
|
489
|
-
const index = first?.indexOf("--config");
|
|
490
|
-
assert.equal(first?.[Number(index) + 1], "/tmp/env-policy.toml");
|
|
491
|
-
});
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
describe("global opt-out (/appa off marker)", () => {
|
|
495
|
-
it("gates every session by default, with no markers or env", async () => {
|
|
496
|
-
const dir = workDir();
|
|
497
|
-
withMock(dir, "allow");
|
|
498
|
-
delete process.env.APPA_GATE;
|
|
499
|
-
const harness = await loadExtension();
|
|
500
|
-
await startSession(harness);
|
|
501
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
502
|
-
assert.equal(call, undefined);
|
|
503
|
-
assert.ok(recordedArgv(dir).length > 0, "hook was invoked without APPA_GATE");
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it("is inert once the global off marker exists", async () => {
|
|
507
|
-
const dir = workDir();
|
|
508
|
-
withMock(dir, "allow");
|
|
509
|
-
delete process.env.APPA_GATE;
|
|
510
|
-
process.env.XDG_CONFIG_HOME = join(dir, "xdg");
|
|
511
|
-
mkdirSync(join(dir, "xdg", "pi-openappa"), { recursive: true });
|
|
512
|
-
writeFileSync(join(dir, "xdg", "pi-openappa", "off"), "");
|
|
513
|
-
const harness = await loadExtension();
|
|
514
|
-
await startSession(harness);
|
|
515
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
516
|
-
assert.equal(call, undefined);
|
|
517
|
-
assert.deepEqual(recordedArgv(dir), []);
|
|
518
|
-
});
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
describe("/appa on|off", () => {
|
|
522
|
-
it("off writes the global marker and ungates; on clears it and re-gates", async () => {
|
|
523
|
-
const dir = workDir();
|
|
524
|
-
withMock(dir, "allow");
|
|
525
|
-
delete process.env.APPA_GATE;
|
|
526
|
-
process.env.XDG_CONFIG_HOME = join(dir, "xdg");
|
|
527
|
-
const harness = await loadExtension();
|
|
528
|
-
const command = harness.commands.get("appa");
|
|
529
|
-
assert.ok(command);
|
|
530
|
-
await command.handler("off", harness.ctx);
|
|
531
|
-
const marker = join(dir, "xdg", "pi-openappa", "off");
|
|
532
|
-
assert.ok(existsSync(marker));
|
|
533
|
-
const argvAfterOff = recordedArgv(dir).length;
|
|
534
|
-
await startSession(harness);
|
|
535
|
-
const optedOut = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
536
|
-
assert.equal(optedOut, undefined);
|
|
537
|
-
assert.equal(recordedArgv(dir).length, argvAfterOff, "no hook while opted out");
|
|
538
|
-
await command.handler("on", harness.ctx);
|
|
539
|
-
assert.ok(!existsSync(marker));
|
|
540
|
-
await startSession(harness);
|
|
541
|
-
const reGated = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
542
|
-
assert.equal(reGated, undefined);
|
|
543
|
-
assert.ok(recordedArgv(dir).length > argvAfterOff, "hook invoked again after /appa on");
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
it("on also clears the legacy always-on marker", async () => {
|
|
547
|
-
const dir = workDir();
|
|
548
|
-
process.env.XDG_CONFIG_HOME = join(dir, "xdg");
|
|
549
|
-
mkdirSync(join(dir, "xdg", "pi-openappa"), { recursive: true });
|
|
550
|
-
writeFileSync(join(dir, "xdg", "pi-openappa", "always-on"), "");
|
|
551
|
-
delete process.env.APPA_GATE;
|
|
552
|
-
const harness = await loadExtension();
|
|
553
|
-
await harness.commands.get("appa")!.handler("on", harness.ctx);
|
|
554
|
-
assert.ok(!existsSync(join(dir, "xdg", "pi-openappa", "always-on")));
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it("status completes without a runtime", async () => {
|
|
558
|
-
const dir = workDir();
|
|
559
|
-
process.env.XDG_CONFIG_HOME = join(dir, "xdg2");
|
|
560
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
|
|
561
|
-
const harness = await loadExtension();
|
|
562
|
-
await startSession(harness);
|
|
563
|
-
await harness.commands.get("appa")!.handler("status", harness.ctx);
|
|
564
|
-
});
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
describe("/appa command", () => {
|
|
568
|
-
it("reports protection state without a runtime", async () => {
|
|
569
|
-
const dir = workDir();
|
|
570
|
-
delete process.env.APPA_GATE;
|
|
571
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799";
|
|
572
|
-
const harness = await loadExtension();
|
|
573
|
-
await startSession(harness);
|
|
574
|
-
const command = harness.commands.get("appa");
|
|
575
|
-
assert.ok(command, "/appa registered");
|
|
576
|
-
// No UI in this harness; the handler must still complete without throwing.
|
|
577
|
-
await command.handler("", harness.ctx);
|
|
578
|
-
});
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
describe("no-policy downgrade (runs unprotected)", () => {
|
|
582
|
-
function noPolicySetup(dir: string): void {
|
|
583
|
-
delete process.env.APPA_GATE;
|
|
584
|
-
delete process.env.APPA_CONFIG;
|
|
585
|
-
delete process.env.APPA_HOOK_BIN;
|
|
586
|
-
// The mock stands in for the default `appa` binary: present on PATH (so
|
|
587
|
-
// no auto-install triggers), crashing on ensure-runtime (spawn works).
|
|
588
|
-
const binDir = join(dir, "bin");
|
|
589
|
-
mkdirSync(binDir, { recursive: true });
|
|
590
|
-
const asAppa = join(binDir, "appa");
|
|
591
|
-
copyFileSync(MOCK_BIN, asAppa);
|
|
592
|
-
chmodSync(asAppa, 0o755);
|
|
593
|
-
process.env.MOCK_MODE = "crash";
|
|
594
|
-
process.env.MOCK_RECORD = join(dir, "record.jsonl");
|
|
595
|
-
process.env.APPA_RUNTIME_URL = "http://127.0.0.1:8799"; // nothing answers
|
|
596
|
-
process.env.APPA_INSTALL_CMD = "exit 1"; // tripwire: must never run
|
|
597
|
-
process.env.PATH = hermeticPath(binDir);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
it("runs the session unprotected with one warning when no policy exists", async () => {
|
|
601
|
-
const dir = workDir();
|
|
602
|
-
noPolicySetup(dir);
|
|
603
|
-
const harness = await loadExtension();
|
|
604
|
-
const notices = enableUiSpy(harness);
|
|
605
|
-
await startSession(harness);
|
|
606
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
607
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
608
|
-
assert.ok(warnings[0]?.[1].includes("runs unprotected"));
|
|
609
|
-
// The initial SessionStart went out; tool calls afterwards do not.
|
|
610
|
-
assert.equal(recordedLines(dir).length, 1);
|
|
611
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
612
|
-
assert.equal(call, undefined);
|
|
613
|
-
assert.equal(recordedLines(dir).length, 1, "no hook while unprotected");
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
it("stays fail-closed when APPA's default policy exists", async () => {
|
|
617
|
-
const dir = workDir();
|
|
618
|
-
noPolicySetup(dir);
|
|
619
|
-
mkdirSync(join(process.env.XDG_CONFIG_HOME!, "appa"), { recursive: true });
|
|
620
|
-
writeFileSync(join(process.env.XDG_CONFIG_HOME!, "appa", "appa.toml"), "");
|
|
621
|
-
const harness = await loadExtension();
|
|
622
|
-
const notices = enableUiSpy(harness);
|
|
623
|
-
await startSession(harness);
|
|
624
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
625
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
626
|
-
assert.ok(warnings[0]?.[1].includes("did not answer"));
|
|
627
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
628
|
-
assert.equal(call.block, true);
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it("stays fail-closed with a named APPA_CONFIG", async () => {
|
|
632
|
-
const dir = workDir();
|
|
633
|
-
noPolicySetup(dir);
|
|
634
|
-
process.env.APPA_CONFIG = "/tmp/named-but-broken.toml";
|
|
635
|
-
const harness = await loadExtension();
|
|
636
|
-
const notices = enableUiSpy(harness);
|
|
637
|
-
await startSession(harness);
|
|
638
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
639
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
640
|
-
assert.ok(warnings[0]?.[1].includes("did not answer"));
|
|
641
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
642
|
-
assert.equal(call.block, true);
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("stays fail-closed when a runtime is already answering", async () => {
|
|
646
|
-
const dir = workDir();
|
|
647
|
-
noPolicySetup(dir);
|
|
648
|
-
const server = createServer((req, res) => {
|
|
649
|
-
res.end(req.url?.includes("health") ? "ok" : "");
|
|
650
|
-
});
|
|
651
|
-
await new Promise<void>((ready) => server.listen(0, "127.0.0.1", ready));
|
|
652
|
-
const address = server.address() as { port: number };
|
|
653
|
-
process.env.APPA_RUNTIME_URL = `http://127.0.0.1:${address.port}`;
|
|
654
|
-
try {
|
|
655
|
-
const harness = await loadExtension();
|
|
656
|
-
const notices = enableUiSpy(harness);
|
|
657
|
-
await startSession(harness);
|
|
658
|
-
const warnings = notices.filter(([kind]) => kind === "warning");
|
|
659
|
-
assert.equal(warnings.length, 1, JSON.stringify(notices));
|
|
660
|
-
assert.ok(warnings[0]?.[1].includes("did not answer"));
|
|
661
|
-
const call = await harness.handlers.get("tool_call")!(bashCall, harness.ctx);
|
|
662
|
-
assert.equal(call.block, true);
|
|
663
|
-
} finally {
|
|
664
|
-
server.close();
|
|
665
|
-
}
|
|
666
|
-
});
|
|
667
|
-
});
|