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/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`
|
|
65
|
-
`appa hook --ensure-runtime
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
|
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
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
7
|
-
* fail-closed while gated: if
|
|
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
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
`
|
|
83
|
-
"
|
|
84
|
-
"
|
|
122
|
+
"`appa` was not found — installing the OpenAPPA runtime " +
|
|
123
|
+
"(https://openappa.com/install.sh)…",
|
|
124
|
+
"info",
|
|
85
125
|
);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (retry.binaryMissing) {
|
|
126
|
+
const install = await installAppa();
|
|
127
|
+
if (install.exitCode !== 0) {
|
|
89
128
|
warnedAlready = true;
|
|
90
129
|
notify(
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
...(
|
|
213
|
+
...(config !== undefined ? { config } : {}),
|
|
159
214
|
};
|
|
160
215
|
}
|
|
161
216
|
|