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 CHANGED
@@ -18,7 +18,8 @@ pi event ◀── enforce ◀── decision ◀─────────
18
18
  (the extension installs it automatically when missing) — version 0.31.x
19
19
  verified; see `docs/wire-notes.md` for the recorded contract
20
20
  - An APPA runtime listening on loopback (default `127.0.0.1:8787`)
21
- - A policy (`appa.toml`) that declares the tools your sessions may use
21
+ - A policy (`appa.toml`) that declares the tools your sessions may use —
22
+ `/appa init` writes a starter one, or copy `templates/appa.toml`
22
23
 
23
24
  ## Install
24
25
 
@@ -61,10 +62,14 @@ empty content falls back to `APPA_CONFIG` or APPA's default:
61
62
  cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
62
63
  ```
63
64
 
64
- A protected session brings the runtime up on its own: `session_start` invokes
65
- `appa hook --ensure-runtime`, passing `--config "$APPA_CONFIG"` when set and
66
- otherwise letting APPA use its own default policy (`~/.config/appa/appa.toml`).
67
- Protected sessions therefore need zero manual server management. A custom
65
+ A protected session brings the runtime up on its own: `session_start` sends
66
+ `appa hook --ensure-runtime` (passing `--config` when a policy is resolved),
67
+ but **does not wait for it** — the boot runs on a serialized background lane,
68
+ so a cold runtime, a first-run auto-install, or a slow start never sits in
69
+ Pi's session-start path. Ordering is preserved: the SessionStart payload
70
+ always lands before the first `PreToolUse`, and a runtime that cannot
71
+ answer fails that first call closed with the reason (silence never means
72
+ yes). A custom
68
73
  `APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
69
74
  refuses with exactly that reason instead of guessing.
70
75
 
@@ -85,11 +90,12 @@ Opted-out sessions never invoke the hook.
85
90
 
86
91
  ## Configuration
87
92
 
88
- | Variable | Default | Meaning |
93
+ | Source | Default | Meaning |
89
94
  |---|---|---|
90
95
  | `APPA_GATE` | unset | `1` forces protection on for this launch; `0` forces it off |
91
96
  | `.pi/openappa` | absent | Project marker: names that project's policy and forces protection on |
92
97
  | `.pi/no-openappa` | absent | Project opt-out: sessions started there run unguarded |
98
+ | `~/.config/pi-openappa/settings.json` | absent | File settings: `config`, `runtimeUrl`, `hookBin`, `hookTimeoutMs`; env always wins, project markers beat `config` |
93
99
  | `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
94
100
  | `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
95
101
  | `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
@@ -97,6 +103,58 @@ Opted-out sessions never invoke the hook.
97
103
  | `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
98
104
  | `APPA_INSTALL_TIMEOUT_MS` | `120000` | Kill a stuck auto-install after this long |
99
105
 
106
+ Policy resolution order: `APPA_CONFIG`, then the project marker's content,
107
+ then `settings.json`'s `config`, then APPA's own default
108
+ (`~/.config/appa/appa.toml`).
109
+
110
+ ## Starter policy: `/appa init`
111
+
112
+ `templates/appa.toml` is a complete, self-contained policy written for Pi.
113
+ It gates **flows, not tools**: sources mark data, sinks check it.
114
+
115
+ | When a session carries… | it is refused at… |
116
+ |---|---|
117
+ | web text (`curl`/`wget`, `web_explore`, context7) | editing any existing file except docs/openspec/`*.md` (research lands in docs, it doesn't rewrite code); writing tests, devops (Makefile, justfile, scripts, CI, Dockerfile), infra (terraform, k8s, helm…), or credential-shaped paths; `git push` / `gh …`; `mem_save`/`mem_update` (poisoned memory must not persist) |
118
+ | `.env`/credentials (read or commanded) | web tools (a secret-narrowed session cannot prove a query shareable) — trusted docs domains carved out for `curl`; bash output returns masked via `redact-secrets` |
119
+ | anything | editing appa's own policy files |
120
+ | nothing (clean session) | nowhere — reads, edits, tests, commits, pushes all flow |
121
+
122
+ An undeclared tool is refused before it runs (deny-by-default; a `*`
123
+ wildcard requires an annotator, so refusing is the only annotator-free
124
+ stance — the commented `builtin = "llm"` block shows the classifier
125
+ upgrade). Selectors use **Pi argument names** (`Read(path:…)`, not Claude
126
+ Code's `file_path` — the stock battery's never match a Pi call), and the
127
+ policy is **static rules only**: nothing host-coupled, nothing that can be
128
+ unreachable. Taint lives in the trajectory label and only narrows — the
129
+ manual reset is a **new session** (resume/fork inherit it; there is no
130
+ `/untaint` by design).
131
+
132
+ Install it one of three ways:
133
+
134
+ ```sh
135
+ /appa init # inside Pi: writes ~/.config/appa/appa.toml (+ settings.json)
136
+ /appa init project # writes ./appa.toml and the .pi/openappa marker
137
+ cp templates/appa.toml ~/.config/appa/appa.toml # from a checkout
138
+ ```
139
+
140
+ `init` never clobbers: rerun with `--force` to overwrite. Subcommands
141
+ autocomplete (`on`, `off`, `init`, `init project`, `status`).
142
+
143
+ ### Policy-as-code
144
+
145
+ The policy is tested like code — no runtime needed, only the `appa` CLI:
146
+
147
+ ```sh
148
+ just policy-check # templates/appa.toml loads
149
+ just policy-coverage # every known Pi tool declared (Day E: refusals fail)
150
+ just policy-test # replay the usage-day traces (Days A–D, F)
151
+ ```
152
+
153
+ `traces/*.appa` are line-based replays (`<canonical-tool> {` / one `arg:`
154
+ JSON value per line / `}` / `expect allow|withhold|deny`); calls in one
155
+ file share a trajectory, so taint accumulates exactly as live. All three
156
+ gates run in `just check`.
157
+
100
158
  `/appa` reports protection, opt-out state, and runtime health; `/appa off`
101
159
  disables protection globally (marker `~/.config/pi-openappa/off`) and
102
160
  `/appa on` re-enables it, taking effect immediately including the
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Thin by design: translate Pi events to `appa hook` invocations and enforce
5
5
  * the answer. No policy logic lives here; the APPA runtime owns every
6
- * decision. Protection is opt-in per session (APPA_GATE=1 at launch) and
7
- * fail-closed while gated: if the runtime cannot answer, the call is blocked.
6
+ * decision. Protection is ON by default (opt out with APPA_GATE=0, a project
7
+ * `.pi/no-openappa` marker, or `/appa off`) and fail-closed while gated: if
8
+ * the runtime cannot answer, the call is blocked. A missing `appa` binary
9
+ * self-installs once on the background lane; a first run with no policy
10
+ * anywhere and no runtime runs the session unprotected with one warning.
8
11
  *
9
12
  * Wire facts and constraints: docs/wire-notes.md
10
13
  */
11
14
 
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
12
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
19
  import {
14
20
  parseCallDecision,
@@ -20,13 +26,16 @@ import {
20
26
  stopPayload,
21
27
  toolResponseFrom,
22
28
  } from "../src/adapter.ts";
23
- import { invokeAppaHook, resolveHookBin, type HookOutcome } from "../src/hook-client.ts";
29
+ import { invokeAppaHook, resolveHookBin } from "../src/hook-client.ts";
30
+ import type { InvokeOptions } from "../src/hook-client.ts";
24
31
  import { installAppa, type InstallOutcome } from "../src/installer.ts";
25
32
  import {
33
+ appaDefaultConfigPath,
26
34
  appaDefaultPolicyExists,
27
35
  captureGate,
28
36
  checkHealth,
29
37
  setGloballyOff,
38
+ settingsPath,
30
39
  type GateState,
31
40
  } from "../src/gate.ts";
32
41
 
@@ -35,6 +44,14 @@ interface TextPart {
35
44
  text: string;
36
45
  }
37
46
 
47
+ /** The starter policy `/appa init` writes (templates/appa.toml in this package). */
48
+ function templateToml(): string {
49
+ return readFileSync(
50
+ fileURLToPath(new URL("../templates/appa.toml", import.meta.url)),
51
+ "utf8",
52
+ );
53
+ }
54
+
38
55
  export default function (pi: ExtensionAPI): void {
39
56
  /** Launch-fixed protection state; null until the session starts. */
40
57
  let gate: GateState | null = null;
@@ -48,101 +65,137 @@ export default function (pi: ExtensionAPI): void {
48
65
  */
49
66
  const pendingInputs = new Map<string, Record<string, unknown>>();
50
67
 
68
+ /**
69
+ * Serialized hook lane. Every `appa hook` invocation for this session runs
70
+ * through here in order, so the SessionStart payload (with
71
+ * `--ensure-runtime`) always lands before the first PreToolUse even though
72
+ * session_start does not wait for it. Startup stays off the fast-start
73
+ * lane: the runtime boot — and a first run's auto-install — is paid by
74
+ * whichever tool call needs the runtime first, and a runtime that cannot
75
+ * answer fails that call closed with the reason — never a slower session
76
+ * start.
77
+ */
78
+ let lane: Promise<unknown> = Promise.resolve();
79
+ const enqueue = <T>(run: () => Promise<T>): Promise<T> => {
80
+ const outcome = lane.then(run, run);
81
+ lane = outcome.then(
82
+ () => undefined,
83
+ () => undefined,
84
+ );
85
+ return outcome;
86
+ };
87
+
51
88
  const gated = (): boolean => gate?.gated === true && !unprotected;
52
89
 
90
+ const hookOptions = (state: GateState, extra: InvokeOptions = {}): InvokeOptions => {
91
+ const options: InvokeOptions = { ...extra };
92
+ if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
93
+ return options;
94
+ };
95
+
53
96
  pi.on("session_start", async (event, ctx) => {
54
97
  gate = captureGate(process.env, ctx.cwd);
55
98
  sessionId = ctx.sessionManager.getSessionId();
56
99
  if (!gate.gated) return;
57
100
  unprotected = false;
58
-
59
- const payload = sessionStartPayload(sessionId, event.reason, ctx.cwd);
60
- const options = {
61
- ensureRuntime: true,
62
- ...(gate.config !== undefined ? { config: gate.config } : {}),
63
- };
64
- const notify = (message: string, kind: "info" | "warning"): void => {
65
- if (ctx.hasUI) ctx.ui.notify(message, kind);
66
- };
67
- let outcome = await invokeAppaHook(payload, options);
68
- let warnedAlready = false;
69
- // A missing default `appa` is self-provisioned: run the official install
70
- // script once, then retry bringing the runtime up. Custom APPA_HOOK_BINs
71
- // are the user's own and never auto-installed.
72
- if (outcome.binaryMissing && resolveHookBin(process.env) === "appa") {
73
- notify(
74
- "`appa` was not found — installing the OpenAPPA runtime " +
75
- "(https://openappa.com/install.sh)…",
76
- "info",
77
- );
78
- const install = await installAppa();
79
- if (install.exitCode !== 0) {
80
- warnedAlready = true;
101
+ const state = gate;
102
+ const options: InvokeOptions = { ensureRuntime: true };
103
+ if (state.config !== undefined) options.config = state.config;
104
+ if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
105
+ // Backgrounded on purpose (see `lane`): a cold runtime boot — or a first
106
+ // run that still has to install the runtime — must not sit in Pi's
107
+ // session-start path. Auto-install, the retry, and the first-run
108
+ // unprotected decision all run serialized on the lane; failure surfaces
109
+ // as a warning when it settles.
110
+ void enqueue(async () => {
111
+ const notify = (message: string, kind: "info" | "warning"): void => {
112
+ if (ctx.hasUI) ctx.ui.notify(message, kind);
113
+ };
114
+ const payload = sessionStartPayload(sessionId, event.reason, ctx.cwd);
115
+ let outcome = await invokeAppaHook(payload, options);
116
+ let warnedAlready = false;
117
+ // A missing default `appa` is self-provisioned: run the official install
118
+ // script once, then retry bringing the runtime up. Custom APPA_HOOK_BINs
119
+ // are the user's own and never auto-installed.
120
+ if (outcome.binaryMissing && resolveHookBin(process.env) === "appa") {
81
121
  notify(
82
- `OpenAPPA auto-install failed: ${outcomeTail(install)}. ` +
83
- "Tool calls stay blocked; install `appa` manually (see the README) or run /appa off.",
84
- "warning",
122
+ "`appa` was not found — installing the OpenAPPA runtime " +
123
+ "(https://openappa.com/install.sh)…",
124
+ "info",
85
125
  );
86
- } else {
87
- const retry = await invokeAppaHook(payload, options);
88
- if (retry.binaryMissing) {
126
+ const install = await installAppa();
127
+ if (install.exitCode !== 0) {
89
128
  warnedAlready = true;
90
129
  notify(
91
- "OpenAPPA auto-install finished but `appa` is still not on PATH. " +
92
- `Installer output: ${outcomeTail(install)} — restart the session once PATH has it.`,
130
+ `OpenAPPA auto-install failed: ${outcomeTail(install)}. ` +
131
+ "Tool calls stay blocked; install `appa` manually (see the README) or run /appa off.",
93
132
  "warning",
94
133
  );
134
+ } else {
135
+ const retry = await invokeAppaHook(payload, options);
136
+ if (retry.binaryMissing) {
137
+ warnedAlready = true;
138
+ notify(
139
+ "OpenAPPA auto-install finished but `appa` is still not on PATH. " +
140
+ `Installer output: ${outcomeTail(install)} — restart the session once PATH has it.`,
141
+ "warning",
142
+ );
143
+ }
144
+ outcome = retry;
95
145
  }
96
- outcome = retry;
97
146
  }
98
- }
99
- if (outcome.exitCode !== 0) {
100
- // No policy anywhere and nothing answering: run this session
101
- // unprotected with one warning instead of fail-closed (the configured
102
- // default). A named policy that fails stays fail-closed below.
103
- if (
104
- resolveHookBin(process.env) === "appa" &&
105
- gate.config === undefined &&
106
- !appaDefaultPolicyExists(process.env) &&
107
- !(await checkHealth(gate.runtimeUrl)).ok
108
- ) {
109
- unprotected = true;
110
- if (!warnedAlready) {
111
- notify(
112
- "OpenAPPA is on by default, but no policy exists (APPA_CONFIG, " +
113
- ".pi/openappa, or ~/.config/appa/appa.toml) and no runtime answers " +
114
- `at ${gate.runtimeUrl} — this session runs unprotected. ` +
115
- "Write a policy, or run /appa off.",
147
+ if (outcome.exitCode !== 0) {
148
+ // No policy anywhere and nothing answering: run this session
149
+ // unprotected with one warning instead of fail-closed (the configured
150
+ // default). A named policy that fails stays fail-closed below.
151
+ if (
152
+ resolveHookBin(process.env) === "appa" &&
153
+ state.config === undefined &&
154
+ !appaDefaultPolicyExists(process.env) &&
155
+ !(await checkHealth(state.runtimeUrl)).ok
156
+ ) {
157
+ unprotected = true;
158
+ if (!warnedAlready) {
159
+ notify(
160
+ "OpenAPPA is on by default, but no policy exists (run /appa init, " +
161
+ "or set APPA_CONFIG, .pi/openappa, or ~/.config/appa/appa.toml) and no runtime answers " +
162
+ `at ${state.runtimeUrl} — this session runs unprotected. ` +
163
+ "Write a policy, or run /appa off.",
164
+ "warning",
165
+ );
166
+ }
167
+ return;
168
+ }
169
+ if (ctx.hasUI && !warnedAlready) {
170
+ const remedy =
171
+ state.config === undefined
172
+ ? " Provide a policy (this project's .pi/openappa, /appa init, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
173
+ : "";
174
+ ctx.ui.notify(
175
+ `OpenAPPA gated but the runtime did not answer (${state.runtimeUrl}): ` +
176
+ `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
177
+ "Tool calls will be blocked until it answers.",
116
178
  "warning",
117
179
  );
118
180
  }
119
- return;
120
- }
121
- if (ctx.hasUI && !warnedAlready) {
122
- const remedy =
123
- gate.config === undefined
124
- ? " Provide a policy (this project's .pi/openappa, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
125
- : "";
126
- ctx.ui.notify(
127
- `OpenAPPA gated but the runtime did not answer (${gate.runtimeUrl}): ` +
128
- `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
129
- "Tool calls will be blocked until it answers.",
130
- "warning",
131
- );
132
181
  }
133
- }
182
+ });
134
183
  });
135
184
 
136
185
  pi.on("before_agent_start", async (event, ctx) => {
137
186
  if (!gated()) return;
138
187
  // The prompt event establishes the turn boundary; it does not gate.
139
- await invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd));
188
+ await enqueue(() =>
189
+ invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd), hookOptions(gate!)),
190
+ );
140
191
  });
141
192
 
142
193
  pi.on("tool_call", async (event, ctx) => {
143
194
  if (!gated()) return;
144
195
  const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
145
- const outcome = await invokeAppaHook(payload);
196
+ const outcome = await enqueue(() =>
197
+ invokeAppaHook(payload, hookOptions(gate!)),
198
+ );
146
199
  const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
147
200
  if (decision.type === "deny") {
148
201
  return { block: true, reason: `appa: ${decision.reason}` };
@@ -167,7 +220,9 @@ export default function (pi: ExtensionAPI): void {
167
220
  response,
168
221
  ctx.cwd,
169
222
  );
170
- const outcome = await invokeAppaHook(payload);
223
+ const outcome = await enqueue(() =>
224
+ invokeAppaHook(payload, hookOptions(gate!)),
225
+ );
171
226
  const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
172
227
  if (decision.type === "pass") {
173
228
  return undefined;
@@ -181,7 +236,9 @@ export default function (pi: ExtensionAPI): void {
181
236
  pi.on("turn_end", async () => {
182
237
  if (!gated()) return;
183
238
  // Turn completion is reported, never gated (matching --turn-end).
184
- await invokeAppaHook(stopPayload(sessionId), { turnEnd: true });
239
+ await enqueue(() =>
240
+ invokeAppaHook(stopPayload(sessionId), hookOptions(gate!, { turnEnd: true })),
241
+ );
185
242
  });
186
243
 
187
244
  pi.on("session_shutdown", () => {
@@ -189,9 +246,36 @@ export default function (pi: ExtensionAPI): void {
189
246
  });
190
247
 
191
248
  pi.registerCommand("appa", {
192
- description: "Show OpenAPPA status; `appa on|off` toggles protection globally",
249
+ description:
250
+ "OpenAPPA guard: status, `appa on|off` global toggle, `appa init [project]` writes a starter policy",
251
+ getArgumentCompletions: (argumentPrefix) => {
252
+ const items = [
253
+ {
254
+ value: "on",
255
+ label: "on",
256
+ description: "Re-enable default-on protection for every session",
257
+ },
258
+ { value: "off", label: "off", description: "Disable protection globally (/appa on re-enables)" },
259
+ {
260
+ value: "init",
261
+ label: "init",
262
+ description: "Write the starter policy to ~/.config/appa/appa.toml",
263
+ },
264
+ {
265
+ value: "init project",
266
+ label: "init project",
267
+ description: "Write ./appa.toml plus the .pi/openappa project marker",
268
+ },
269
+ { value: "status", label: "status", description: "Show protection and runtime health" },
270
+ ];
271
+ const prefix = argumentPrefix.trim();
272
+ const hits = prefix === "" ? items : items.filter((item) => item.value.startsWith(prefix));
273
+ return hits.length > 0 ? hits : null;
274
+ },
193
275
  handler: async (args, ctx) => {
194
- const arg = args.trim();
276
+ const tokens = args.trim().split(/\s+/).filter((token) => token !== "");
277
+ const arg = tokens[0] ?? "";
278
+
195
279
  if (arg === "on" || arg === "off") {
196
280
  setGloballyOff(process.env, arg === "off");
197
281
  gate = captureGate(process.env, ctx.cwd);
@@ -205,6 +289,14 @@ export default function (pi: ExtensionAPI): void {
205
289
  }
206
290
  return;
207
291
  }
292
+
293
+ if (arg === "init") {
294
+ const scope = tokens[1] === "project" ? "project" : "user";
295
+ const force = tokens.includes("--force");
296
+ await runInit(scope, force, ctx);
297
+ return;
298
+ }
299
+
208
300
  const state = gate ?? captureGate(process.env, ctx.cwd);
209
301
  const mode =
210
302
  state.source === "project"
@@ -237,6 +329,59 @@ export default function (pi: ExtensionAPI): void {
237
329
  }
238
330
  },
239
331
  });
332
+
333
+ /** `/appa init` — write the starter policy (and settings) without clobbering. */
334
+ async function runInit(
335
+ scope: "user" | "project",
336
+ force: boolean,
337
+ ctx: { cwd: string; hasUI: boolean; ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
338
+ ): Promise<void> {
339
+ const notify = (message: string, type: "info" | "warning" | "error" = "info") => {
340
+ if (ctx.hasUI) ctx.ui.notify(message, type);
341
+ };
342
+ try {
343
+ if (scope === "project") {
344
+ const policy = join(ctx.cwd, "appa.toml");
345
+ const marker = join(ctx.cwd, ".pi", "openappa");
346
+ if (existsSync(policy) && !force) {
347
+ notify(`${policy} already exists; rerun with --force to overwrite it.`, "warning");
348
+ return;
349
+ }
350
+ mkdirSync(dirname(policy), { recursive: true });
351
+ writeFileSync(policy, templateToml());
352
+ if (!existsSync(marker)) {
353
+ mkdirSync(dirname(marker), { recursive: true });
354
+ writeFileSync(marker, "appa.toml\n");
355
+ }
356
+ notify(
357
+ `Wrote ${policy} and the .pi/openappa marker. Sessions started in this directory are protected; new sessions pick the policy up (trajectories keep the policy they opened with).`,
358
+ );
359
+ return;
360
+ }
361
+ const policy = appaDefaultConfigPath(process.env);
362
+ if (existsSync(policy) && !force) {
363
+ notify(
364
+ `${policy} already exists; rerun with --force to overwrite it, or /appa init project for one project.`,
365
+ "warning",
366
+ );
367
+ return;
368
+ }
369
+ mkdirSync(dirname(policy), { recursive: true });
370
+ writeFileSync(policy, templateToml());
371
+ const settings = settingsPath(process.env);
372
+ const lines: string[] = [`Wrote the starter policy to ${policy}.`];
373
+ if (!existsSync(settings)) {
374
+ mkdirSync(dirname(settings), { recursive: true });
375
+ writeFileSync(settings, `${JSON.stringify({ config: policy }, null, 2)}\n`);
376
+ lines.push(`Wrote ${settings} pointing at it.`);
377
+ }
378
+ lines.push("Protection is on by default; /appa off disables it, /appa init project scopes a policy to one project.");
379
+ notify(lines.join(" "));
380
+ } catch (error) {
381
+ const detail = error instanceof Error ? error.message : String(error);
382
+ notify(`/appa init failed: ${detail}`, "error");
383
+ }
384
+ }
240
385
  }
241
386
 
242
387
  /** Last ~200 chars of installer output, whitespace-normalized, for notices. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Thin OpenAPPA guard extension for Pi: gates tool calls through the APPA runtime. No policy logic lives here.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,6 +19,13 @@
19
19
  "homepage": "https://github.com/aemonge-dev/pi-openappa#readme",
20
20
  "bugs": "https://github.com/aemonge-dev/pi-openappa/issues",
21
21
  "type": "module",
22
+ "files": [
23
+ "extensions/",
24
+ "src/",
25
+ "templates/",
26
+ "traces/",
27
+ "docs/"
28
+ ],
22
29
  "pi": {
23
30
  "extensions": ["./extensions/index.ts"]
24
31
  },
package/src/gate.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * Otherwise the session is protected (the default).
11
11
  *
12
12
  * An explicit APPA_CONFIG always wins as the policy source; otherwise a
13
- * project marker's content is used; otherwise APPA's own default. The gate is
13
+ * project marker's content is used; otherwise the settings file's `config`;
14
+ * otherwise APPA's own default. The gate is
14
15
  * captured once per session so a session cannot disable its own protection
15
16
  * mid-run; `/appa on|off` are deliberate user commands and do re-resolve.
16
17
  * The legacy `always-on` marker from opt-in days is ignored; `/appa on`
@@ -39,6 +40,55 @@ export function legacyAlwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
39
40
  return join(baseConfigDir(env), "pi-openappa", "always-on");
40
41
  }
41
42
 
43
+ /** The policy a gated session auto-starts the runtime with, by APPA default. */
44
+ export function appaDefaultConfigPath(env: NodeJS.ProcessEnv): string {
45
+ return join(baseConfigDir(env), "appa", "appa.toml");
46
+ }
47
+
48
+ /** File-backed settings for this extension (see `ExtensionSettings`). */
49
+ export function settingsPath(env: NodeJS.ProcessEnv): string {
50
+ return join(baseConfigDir(env), "pi-openappa", "settings.json");
51
+ }
52
+
53
+ /**
54
+ * Keys read from `settings.json`. Every key is optional; environment
55
+ * variables of the same meaning always win over the file, and the file
56
+ * always wins over the built-in defaults. Written by `/appa init`.
57
+ */
58
+ export interface ExtensionSettings {
59
+ /** Policy path passed as `--config` (the APPA_CONFIG fallback). */
60
+ config?: string;
61
+ /** Runtime endpoint (the APPA_RUNTIME_URL fallback). */
62
+ runtimeUrl?: string;
63
+ /** Hook binary (the APPA_HOOK_BIN fallback). */
64
+ hookBin?: string;
65
+ /** Hook timeout in ms (the APPA_HOOK_TIMEOUT_MS fallback). */
66
+ hookTimeoutMs?: number;
67
+ }
68
+
69
+ /** Read settings.json; a missing or malformed file resolves to `{}`. */
70
+ export function readSettings(env: NodeJS.ProcessEnv): ExtensionSettings {
71
+ try {
72
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath(env), "utf8"));
73
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
74
+ return {};
75
+ }
76
+ const out: ExtensionSettings = {};
77
+ const record = parsed as Record<string, unknown>;
78
+ for (const key of ["config", "runtimeUrl", "hookBin"] as const) {
79
+ const value = record[key];
80
+ if (typeof value === "string" && value !== "") out[key] = value;
81
+ }
82
+ const timeout = record["hookTimeoutMs"];
83
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
84
+ out.hookTimeoutMs = timeout;
85
+ }
86
+ return out;
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
42
92
  export function projectMarkerPath(cwd: string): string {
43
93
  return join(cwd, ".pi", "openappa");
44
94
  }
@@ -88,12 +138,14 @@ export interface GateState {
88
138
  gated: boolean;
89
139
  /** Most specific reason this session is (or is not) protected. */
90
140
  source: GateSource;
91
- /** Policy for auto-start: explicit env wins, then project marker content. */
141
+ /** Policy for auto-start: env, then project marker, then settings file. */
92
142
  config?: string;
93
143
  /** Runtime URL for health reporting (the hook binary reads it from env). */
94
144
  runtimeUrl: string;
95
145
  /** Hook binary used for reporting. */
96
146
  hookBin: string;
147
+ /** Hook timeout in ms when settings pin one; otherwise the default applies. */
148
+ hookTimeoutMs?: number;
97
149
  }
98
150
 
99
151
  /** Read a project marker's optional policy path; empty content resolves to none. */
@@ -108,9 +160,11 @@ function projectConfig(cwd: string): string | undefined {
108
160
  }
109
161
 
110
162
  export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
163
+ const settings = readSettings(env);
111
164
  const base = {
112
- runtimeUrl: env.APPA_RUNTIME_URL ?? DEFAULT_RUNTIME_URL,
113
- hookBin: env.APPA_HOOK_BIN ?? "appa",
165
+ runtimeUrl: env.APPA_RUNTIME_URL ?? settings.runtimeUrl ?? DEFAULT_RUNTIME_URL,
166
+ hookBin: env.APPA_HOOK_BIN ?? settings.hookBin ?? "appa",
167
+ ...(settings.hookTimeoutMs !== undefined ? { hookTimeoutMs: settings.hookTimeoutMs } : {}),
114
168
  };
115
169
  const explicitConfig =
116
170
  env.APPA_CONFIG !== undefined && env.APPA_CONFIG !== ""
@@ -120,10 +174,12 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
120
174
  const projectOff = cwd !== undefined && existsSync(projectNoMarkerPath(cwd));
121
175
  const projectCfg =
122
176
  projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
177
+ // Precedence: launch env, then the project marker (per-project intent),
178
+ // then the global settings file (see readSettings).
179
+ const config = explicitConfig ?? projectCfg ?? settings.config;
123
180
 
124
181
  // An explicit launch choice beats every marker.
125
182
  if (env.APPA_GATE === "1") {
126
- const config = explicitConfig ?? projectCfg;
127
183
  return {
128
184
  gated: true,
129
185
  source: "env-on",
@@ -139,7 +195,6 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
139
195
  return { gated: false, source: "project-off", ...base };
140
196
  }
141
197
  if (projectGated) {
142
- const config = explicitConfig ?? projectCfg;
143
198
  return {
144
199
  gated: true,
145
200
  source: "project",
@@ -155,7 +210,7 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
155
210
  gated: true,
156
211
  source: "default",
157
212
  ...base,
158
- ...(explicitConfig !== undefined ? { config: explicitConfig } : {}),
213
+ ...(config !== undefined ? { config } : {}),
159
214
  };
160
215
  }
161
216