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 +63 -6
- package/extensions/index.ts +174 -28
- package/package.json +15 -1
- package/src/gate.ts +62 -6
- 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 -32
- package/test/adapter.test.ts +0 -207
- package/test/extension.test.ts +0 -447
- package/test/fixtures/mock-appa.mjs +0 -104
- package/tsconfig.json +0 -16
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`
|
|
47
|
-
`appa hook --ensure-runtime
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
|
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
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
7
|
-
* fail-closed while gated: if the runtime
|
|
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 {
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
...(
|
|
159
|
+
...(config !== undefined ? { config } : {}),
|
|
104
160
|
};
|
|
105
161
|
}
|
|
106
162
|
return { gated: false, source: "off", ...base };
|