pi-openappa 0.1.0 → 0.3.0

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
@@ -17,7 +17,8 @@ pi event ◀── enforce ◀── decision ◀─────────
17
17
  - The `appa` binary on `PATH` ([install](https://openappa.com)) — version
18
18
  0.31.x verified; see `docs/wire-notes.md` for the recorded contract
19
19
  - An APPA runtime listening on loopback (default `127.0.0.1:8787`)
20
- - A policy (`appa.toml`) that declares the tools your sessions may use
20
+ - A policy (`appa.toml`) that declares the tools your sessions may use —
21
+ `/appa init` writes a starter one, or copy `templates/appa.toml`
21
22
 
22
23
  ## Install
23
24
 
@@ -43,10 +44,13 @@ Protection is opt-in, in one of three ways:
43
44
  protected. `/appa off` disables.
44
45
  - **Per launch:** `APPA_GATE=1 pi` (the `clappa`-style launcher route).
45
46
 
46
- A gated session brings the runtime up on its own: `session_start` invokes
47
- `appa hook --ensure-runtime`, passing `--config "$APPA_CONFIG"` when set and
48
- otherwise letting APPA use its own default policy (`~/.config/appa/appa.toml`).
49
- Protected sessions therefore need zero manual server management. A custom
47
+ A gated session brings the runtime up on its own: `session_start` sends
48
+ `appa hook --ensure-runtime` (passing `--config` when a policy is resolved),
49
+ but **does not wait for it** — the boot runs on a serialized background lane,
50
+ so a cold runtime, a first-run install, or a slow start never sits in Pi's
51
+ session-start path. Ordering is preserved: the SessionStart payload always
52
+ lands before the first `PreToolUse`, and a runtime that cannot answer fails
53
+ that first call closed with the reason (silence never means yes). A custom
50
54
  `APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
51
55
  refuses with exactly that reason instead of guessing.
52
56
 
@@ -57,15 +61,68 @@ every tool call blocked. Ungated sessions never invoke the hook.
57
61
 
58
62
  ## Configuration
59
63
 
60
- | Variable | Default | Meaning |
64
+ | Source | Default | Meaning |
61
65
  |---|---|---|
62
66
  | `APPA_GATE` | unset | `1` protects this session (read once at launch) |
63
67
  | `.pi/openappa` | absent | Project marker: gates sessions started in that directory; optional content = policy path |
68
+ | `~/.config/pi-openappa/settings.json` | absent | File settings: `config`, `runtimeUrl`, `hookBin`, `hookTimeoutMs`; env always wins, project markers beat `config` |
64
69
  | `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
65
70
  | `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
66
71
  | `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
67
72
  | `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
68
73
 
74
+ Policy resolution order: `APPA_CONFIG`, then the project marker's content,
75
+ then `settings.json`'s `config`, then APPA's own default
76
+ (`~/.config/appa/appa.toml`).
77
+
78
+ ## Starter policy: `/appa init`
79
+
80
+ `templates/appa.toml` is a complete, self-contained policy written for Pi.
81
+ It gates **flows, not tools**: sources mark data, sinks check it.
82
+
83
+ | When a session carries… | it is refused at… |
84
+ |---|---|
85
+ | 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) |
86
+ | `.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` |
87
+ | anything | editing appa's own policy files |
88
+ | nothing (clean session) | nowhere — reads, edits, tests, commits, pushes all flow |
89
+
90
+ An undeclared tool is refused before it runs (deny-by-default; a `*`
91
+ wildcard requires an annotator, so refusing is the only annotator-free
92
+ stance — the commented `builtin = "llm"` block shows the classifier
93
+ upgrade). Selectors use **Pi argument names** (`Read(path:…)`, not Claude
94
+ Code's `file_path` — the stock battery's never match a Pi call), and the
95
+ policy is **static rules only**: nothing host-coupled, nothing that can be
96
+ unreachable. Taint lives in the trajectory label and only narrows — the
97
+ manual reset is a **new session** (resume/fork inherit it; there is no
98
+ `/untaint` by design).
99
+
100
+ Install it one of three ways:
101
+
102
+ ```sh
103
+ /appa init # inside Pi: writes ~/.config/appa/appa.toml (+ settings.json)
104
+ /appa init project # writes ./appa.toml and the .pi/openappa marker
105
+ cp templates/appa.toml ~/.config/appa/appa.toml # from a checkout
106
+ ```
107
+
108
+ `init` never clobbers: rerun with `--force` to overwrite. Subcommands
109
+ autocomplete (`on`, `off`, `init`, `init project`, `status`).
110
+
111
+ ### Policy-as-code
112
+
113
+ The policy is tested like code — no runtime needed, only the `appa` CLI:
114
+
115
+ ```sh
116
+ just policy-check # templates/appa.toml loads
117
+ just policy-coverage # every known Pi tool declared (Day E: refusals fail)
118
+ just policy-test # replay the usage-day traces (Days A–D, F)
119
+ ```
120
+
121
+ `traces/*.appa` are line-based replays (`<canonical-tool> {` / one `arg:`
122
+ JSON value per line / `}` / `expect allow|withhold|deny`); calls in one
123
+ file share a trajectory, so taint accumulates exactly as live. All three
124
+ gates run in `just check`.
125
+
69
126
  `/appa` reports protection, always-on state, and runtime health; `/appa on`
70
127
  and `/appa off` toggle always-on protection (marker:
71
128
  `~/.config/pi-openappa/always-on`), taking effect immediately including the
@@ -3,12 +3,16 @@
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 opt-in per session (APPA_GATE=1 at launch, a
7
+ * project marker, or always-on) and fail-closed while gated: if the runtime
8
+ * cannot answer, the call is blocked.
8
9
  *
9
10
  * Wire facts and constraints: docs/wire-notes.md
10
11
  */
11
12
 
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
12
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
17
  import {
14
18
  parseCallDecision,
@@ -20,14 +24,30 @@ import {
20
24
  stopPayload,
21
25
  toolResponseFrom,
22
26
  } from "../src/adapter.ts";
27
+ import type { InvokeOptions } from "../src/hook-client.ts";
23
28
  import { invokeAppaHook } from "../src/hook-client.ts";
24
- import { captureGate, checkHealth, setAlwaysOn, type GateState } from "../src/gate.ts";
29
+ import {
30
+ appaDefaultConfigPath,
31
+ captureGate,
32
+ checkHealth,
33
+ setAlwaysOn,
34
+ settingsPath,
35
+ type GateState,
36
+ } from "../src/gate.ts";
25
37
 
26
38
  interface TextPart {
27
39
  type: "text";
28
40
  text: string;
29
41
  }
30
42
 
43
+ /** The starter policy `/appa init` writes (templates/appa.toml in this package). */
44
+ function templateToml(): string {
45
+ return readFileSync(
46
+ fileURLToPath(new URL("../templates/appa.toml", import.meta.url)),
47
+ "utf8",
48
+ );
49
+ }
50
+
31
51
  export default function (pi: ExtensionAPI): void {
32
52
  /** Launch-fixed protection state; null until the session starts. */
33
53
  let gate: GateState | null = null;
@@ -39,44 +59,78 @@ export default function (pi: ExtensionAPI): void {
39
59
  */
40
60
  const pendingInputs = new Map<string, Record<string, unknown>>();
41
61
 
62
+ /**
63
+ * Serialized hook lane. Every `appa hook` invocation for this session runs
64
+ * through here in order, so the SessionStart payload (with
65
+ * `--ensure-runtime`) always lands before the first PreToolUse even though
66
+ * session_start does not wait for it. Startup stays off the fast-start
67
+ * lane: the runtime boot cost is paid by whichever tool call needs the
68
+ * runtime first, and a runtime that cannot answer fails that call closed
69
+ * with the reason — never a slower session start.
70
+ */
71
+ let lane: Promise<unknown> = Promise.resolve();
72
+ const enqueue = <T>(run: () => Promise<T>): Promise<T> => {
73
+ const outcome = lane.then(run, run);
74
+ lane = outcome.then(
75
+ () => undefined,
76
+ () => undefined,
77
+ );
78
+ return outcome;
79
+ };
80
+
42
81
  const gated = (): boolean => gate?.gated === true;
43
82
 
83
+ const hookOptions = (state: GateState, extra: InvokeOptions = {}): InvokeOptions => {
84
+ const options: InvokeOptions = { ...extra };
85
+ if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
86
+ return options;
87
+ };
88
+
44
89
  pi.on("session_start", async (event, ctx) => {
45
90
  gate = captureGate(process.env, ctx.cwd);
46
91
  sessionId = ctx.sessionManager.getSessionId();
47
92
  if (!gated()) return;
48
-
49
- const outcome = await invokeAppaHook(
50
- sessionStartPayload(sessionId, event.reason, ctx.cwd),
51
- {
52
- ensureRuntime: true,
53
- ...(gate.config !== undefined ? { config: gate.config } : {}),
54
- },
55
- );
56
- if (outcome.exitCode !== 0 && ctx.hasUI) {
57
- const remedy =
58
- gate.config === undefined
59
- ? " Provide a policy (this project's .pi/openappa, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
60
- : "";
61
- ctx.ui.notify(
62
- `OpenAPPA gated but the runtime did not answer (${gate.runtimeUrl}): ` +
63
- `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
64
- "Tool calls will be blocked until it answers.",
65
- "warning",
93
+ const state = gate;
94
+ const options: InvokeOptions = { ensureRuntime: true };
95
+ if (state.config !== undefined) options.config = state.config;
96
+ if (state.hookTimeoutMs !== undefined) options.timeoutMs = state.hookTimeoutMs;
97
+ // Backgrounded on purpose (see `lane`): a cold runtime boot — or a first
98
+ // run that still has to install or update the runtime — must not sit in
99
+ // Pi's session-start path. Failure surfaces as a warning when it settles.
100
+ void enqueue(async () => {
101
+ const outcome = await invokeAppaHook(
102
+ sessionStartPayload(sessionId, event.reason, ctx.cwd),
103
+ options,
66
104
  );
67
- }
105
+ if (outcome.exitCode !== 0 && ctx.hasUI) {
106
+ const remedy =
107
+ options.config === undefined
108
+ ? " Provide a policy (this project's .pi/openappa, /appa init, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
109
+ : "";
110
+ ctx.ui.notify(
111
+ `OpenAPPA gated but the runtime did not answer (${state.runtimeUrl}): ` +
112
+ `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
113
+ "Tool calls will be blocked until it answers.",
114
+ "warning",
115
+ );
116
+ }
117
+ });
68
118
  });
69
119
 
70
120
  pi.on("before_agent_start", async (event, ctx) => {
71
121
  if (!gated()) return;
72
122
  // The prompt event establishes the turn boundary; it does not gate.
73
- await invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd));
123
+ await enqueue(() =>
124
+ invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd), hookOptions(gate!)),
125
+ );
74
126
  });
75
127
 
76
128
  pi.on("tool_call", async (event, ctx) => {
77
129
  if (!gated()) return;
78
130
  const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
79
- const outcome = await invokeAppaHook(payload);
131
+ const outcome = await enqueue(() =>
132
+ invokeAppaHook(payload, hookOptions(gate!)),
133
+ );
80
134
  const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
81
135
  if (decision.type === "deny") {
82
136
  return { block: true, reason: `appa: ${decision.reason}` };
@@ -101,7 +155,9 @@ export default function (pi: ExtensionAPI): void {
101
155
  response,
102
156
  ctx.cwd,
103
157
  );
104
- const outcome = await invokeAppaHook(payload);
158
+ const outcome = await enqueue(() =>
159
+ invokeAppaHook(payload, hookOptions(gate!)),
160
+ );
105
161
  const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
106
162
  if (decision.type === "pass") {
107
163
  return undefined;
@@ -115,7 +171,9 @@ export default function (pi: ExtensionAPI): void {
115
171
  pi.on("turn_end", async () => {
116
172
  if (!gated()) return;
117
173
  // Turn completion is reported, never gated (matching --turn-end).
118
- await invokeAppaHook(stopPayload(sessionId), { turnEnd: true });
174
+ await enqueue(() =>
175
+ invokeAppaHook(stopPayload(sessionId), hookOptions(gate!, { turnEnd: true })),
176
+ );
119
177
  });
120
178
 
121
179
  pi.on("session_shutdown", () => {
@@ -123,9 +181,36 @@ export default function (pi: ExtensionAPI): void {
123
181
  });
124
182
 
125
183
  pi.registerCommand("appa", {
126
- description: "Show OpenAPPA status; `appa on|off` toggles always-on protection",
184
+ description:
185
+ "OpenAPPA guard: status, `appa on|off` always-on, `appa init [project]` writes a starter policy",
186
+ getArgumentCompletions: (argumentPrefix) => {
187
+ const items = [
188
+ {
189
+ value: "on",
190
+ label: "on",
191
+ description: "Protect every future Pi session (always-on marker)",
192
+ },
193
+ { value: "off", label: "off", description: "Disable always-on protection" },
194
+ {
195
+ value: "init",
196
+ label: "init",
197
+ description: "Write the starter policy to ~/.config/appa/appa.toml",
198
+ },
199
+ {
200
+ value: "init project",
201
+ label: "init project",
202
+ description: "Write ./appa.toml plus the .pi/openappa project marker",
203
+ },
204
+ { value: "status", label: "status", description: "Show protection and runtime health" },
205
+ ];
206
+ const prefix = argumentPrefix.trim();
207
+ const hits = prefix === "" ? items : items.filter((item) => item.value.startsWith(prefix));
208
+ return hits.length > 0 ? hits : null;
209
+ },
127
210
  handler: async (args, ctx) => {
128
- const arg = args.trim();
211
+ const tokens = args.trim().split(/\s+/).filter((token) => token !== "");
212
+ const arg = tokens[0] ?? "";
213
+
129
214
  if (arg === "on" || arg === "off") {
130
215
  setAlwaysOn(process.env, arg === "on");
131
216
  gate = captureGate(process.env, ctx.cwd);
@@ -139,6 +224,14 @@ export default function (pi: ExtensionAPI): void {
139
224
  }
140
225
  return;
141
226
  }
227
+
228
+ if (arg === "init") {
229
+ const scope = tokens[1] === "project" ? "project" : "user";
230
+ const force = tokens.includes("--force");
231
+ await runInit(scope, force, ctx);
232
+ return;
233
+ }
234
+
142
235
  const state = gate ?? captureGate(process.env, ctx.cwd);
143
236
  const mode =
144
237
  state.source === "project"
@@ -164,6 +257,59 @@ export default function (pi: ExtensionAPI): void {
164
257
  }
165
258
  },
166
259
  });
260
+
261
+ /** `/appa init` — write the starter policy (and settings) without clobbering. */
262
+ async function runInit(
263
+ scope: "user" | "project",
264
+ force: boolean,
265
+ ctx: { cwd: string; hasUI: boolean; ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
266
+ ): Promise<void> {
267
+ const notify = (message: string, type: "info" | "warning" | "error" = "info") => {
268
+ if (ctx.hasUI) ctx.ui.notify(message, type);
269
+ };
270
+ try {
271
+ if (scope === "project") {
272
+ const policy = join(ctx.cwd, "appa.toml");
273
+ const marker = join(ctx.cwd, ".pi", "openappa");
274
+ if (existsSync(policy) && !force) {
275
+ notify(`${policy} already exists; rerun with --force to overwrite it.`, "warning");
276
+ return;
277
+ }
278
+ mkdirSync(dirname(policy), { recursive: true });
279
+ writeFileSync(policy, templateToml());
280
+ if (!existsSync(marker)) {
281
+ mkdirSync(dirname(marker), { recursive: true });
282
+ writeFileSync(marker, "appa.toml\n");
283
+ }
284
+ notify(
285
+ `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).`,
286
+ );
287
+ return;
288
+ }
289
+ const policy = appaDefaultConfigPath(process.env);
290
+ if (existsSync(policy) && !force) {
291
+ notify(
292
+ `${policy} already exists; rerun with --force to overwrite it, or /appa init project for one project.`,
293
+ "warning",
294
+ );
295
+ return;
296
+ }
297
+ mkdirSync(dirname(policy), { recursive: true });
298
+ writeFileSync(policy, templateToml());
299
+ const settings = settingsPath(process.env);
300
+ const lines: string[] = [`Wrote the starter policy to ${policy}.`];
301
+ if (!existsSync(settings)) {
302
+ mkdirSync(dirname(settings), { recursive: true });
303
+ writeFileSync(settings, `${JSON.stringify({ config: policy }, null, 2)}\n`);
304
+ lines.push(`Wrote ${settings} pointing at it.`);
305
+ }
306
+ lines.push("Protect sessions with /appa on, APPA_GATE=1, or /appa init project.");
307
+ notify(lines.join(" "));
308
+ } catch (error) {
309
+ const detail = error instanceof Error ? error.message : String(error);
310
+ notify(`/appa init failed: ${detail}`, "error");
311
+ }
312
+ }
167
313
  }
168
314
 
169
315
  function joinContent(content: ReadonlyArray<unknown>): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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",
@@ -11,7 +11,21 @@
11
11
  "policy"
12
12
  ],
13
13
  "license": "MIT",
14
+ "author": "aemonge",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/aemonge-dev/pi-openappa.git"
18
+ },
19
+ "homepage": "https://github.com/aemonge-dev/pi-openappa#readme",
20
+ "bugs": "https://github.com/aemonge-dev/pi-openappa/issues",
14
21
  "type": "module",
22
+ "files": [
23
+ "extensions/",
24
+ "src/",
25
+ "templates/",
26
+ "traces/",
27
+ "docs/"
28
+ ],
15
29
  "pi": {
16
30
  "extensions": ["./extensions/index.ts"]
17
31
  },
package/src/gate.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * - always-on mode, persisted by `/appa on` (marker file below).
9
9
  *
10
10
  * An explicit APPA_CONFIG always wins as the policy source; otherwise a
11
- * project marker's content is used; otherwise APPA's own default. The gate is
11
+ * project marker's content is used; otherwise the settings file's `config`;
12
+ * otherwise APPA's own default. The gate is
12
13
  * captured once per session so a session cannot disable its own protection
13
14
  * mid-run; `/appa on|off` are deliberate user commands and do re-resolve.
14
15
  */
@@ -29,6 +30,55 @@ export function alwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
29
30
  return join(baseConfigDir(env), "pi-openappa", "always-on");
30
31
  }
31
32
 
33
+ /** The policy a gated session auto-starts the runtime with, by APPA default. */
34
+ export function appaDefaultConfigPath(env: NodeJS.ProcessEnv): string {
35
+ return join(baseConfigDir(env), "appa", "appa.toml");
36
+ }
37
+
38
+ /** File-backed settings for this extension (see `ExtensionSettings`). */
39
+ export function settingsPath(env: NodeJS.ProcessEnv): string {
40
+ return join(baseConfigDir(env), "pi-openappa", "settings.json");
41
+ }
42
+
43
+ /**
44
+ * Keys read from `settings.json`. Every key is optional; environment
45
+ * variables of the same meaning always win over the file, and the file
46
+ * always wins over the built-in defaults. Written by `/appa init`.
47
+ */
48
+ export interface ExtensionSettings {
49
+ /** Policy path passed as `--config` (the APPA_CONFIG fallback). */
50
+ config?: string;
51
+ /** Runtime endpoint (the APPA_RUNTIME_URL fallback). */
52
+ runtimeUrl?: string;
53
+ /** Hook binary (the APPA_HOOK_BIN fallback). */
54
+ hookBin?: string;
55
+ /** Hook timeout in ms (the APPA_HOOK_TIMEOUT_MS fallback). */
56
+ hookTimeoutMs?: number;
57
+ }
58
+
59
+ /** Read settings.json; a missing or malformed file resolves to `{}`. */
60
+ export function readSettings(env: NodeJS.ProcessEnv): ExtensionSettings {
61
+ try {
62
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath(env), "utf8"));
63
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
64
+ return {};
65
+ }
66
+ const out: ExtensionSettings = {};
67
+ const record = parsed as Record<string, unknown>;
68
+ for (const key of ["config", "runtimeUrl", "hookBin"] as const) {
69
+ const value = record[key];
70
+ if (typeof value === "string" && value !== "") out[key] = value;
71
+ }
72
+ const timeout = record["hookTimeoutMs"];
73
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
74
+ out.hookTimeoutMs = timeout;
75
+ }
76
+ return out;
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
32
82
  export function projectMarkerPath(cwd: string): string {
33
83
  return join(cwd, ".pi", "openappa");
34
84
  }
@@ -54,12 +104,14 @@ export interface GateState {
54
104
  gated: boolean;
55
105
  /** Most specific reason this session is (or is not) protected. */
56
106
  source: GateSource;
57
- /** Policy for auto-start: explicit env wins, then project marker content. */
107
+ /** Policy for auto-start: env, then project marker, then settings file. */
58
108
  config?: string;
59
109
  /** Runtime URL for health reporting (the hook binary reads it from env). */
60
110
  runtimeUrl: string;
61
111
  /** Hook binary used for reporting. */
62
112
  hookBin: string;
113
+ /** Hook timeout in ms when settings pin one; otherwise the default applies. */
114
+ hookTimeoutMs?: number;
63
115
  }
64
116
 
65
117
  /** Read a project marker's optional policy path; empty content resolves to none. */
@@ -74,9 +126,11 @@ function projectConfig(cwd: string): string | undefined {
74
126
  }
75
127
 
76
128
  export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
129
+ const settings = readSettings(env);
77
130
  const base = {
78
- runtimeUrl: env.APPA_RUNTIME_URL ?? DEFAULT_RUNTIME_URL,
79
- hookBin: env.APPA_HOOK_BIN ?? "appa",
131
+ runtimeUrl: env.APPA_RUNTIME_URL ?? settings.runtimeUrl ?? DEFAULT_RUNTIME_URL,
132
+ hookBin: env.APPA_HOOK_BIN ?? settings.hookBin ?? "appa",
133
+ ...(settings.hookTimeoutMs !== undefined ? { hookTimeoutMs: settings.hookTimeoutMs } : {}),
80
134
  };
81
135
  const explicitConfig =
82
136
  env.APPA_CONFIG !== undefined && env.APPA_CONFIG !== ""
@@ -85,7 +139,9 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
85
139
  const projectGated = cwd !== undefined && existsSync(projectMarkerPath(cwd));
86
140
  const projectCfg =
87
141
  projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
88
- const config = explicitConfig ?? projectCfg;
142
+ // Precedence: launch env, then the project marker (per-project intent),
143
+ // then the global settings file (see readSettings).
144
+ const config = explicitConfig ?? projectCfg ?? settings.config;
89
145
 
90
146
  if (env.APPA_GATE === "1" || projectGated) {
91
147
  return {
@@ -100,7 +156,7 @@ export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
100
156
  gated: true,
101
157
  source: "always-on",
102
158
  ...base,
103
- ...(explicitConfig !== undefined ? { config: explicitConfig } : {}),
159
+ ...(config !== undefined ? { config } : {}),
104
160
  };
105
161
  }
106
162
  return { gated: false, source: "off", ...base };