pi-openappa 0.2.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 +85 -66
- package/extensions/index.ts +176 -110
- package/package.json +8 -1
- package/src/gate.ts +78 -75
- package/src/hook-client.ts +1 -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 -39
- package/src/installer.ts +0 -103
- 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
|
@@ -14,92 +14,118 @@ pi event ◀── enforce ◀── decision ◀─────────
|
|
|
14
14
|
|
|
15
15
|
## Requirements
|
|
16
16
|
|
|
17
|
-
- The `appa` binary on `PATH
|
|
18
|
-
|
|
19
|
-
verified; see `docs/wire-notes.md` for the recorded contract
|
|
17
|
+
- The `appa` binary on `PATH` ([install](https://openappa.com)) — version
|
|
18
|
+
0.31.x verified; see `docs/wire-notes.md` for the recorded contract
|
|
20
19
|
- 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
|
|
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`
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
pi install npm:pi-openappa # published
|
|
26
|
+
pi install npm:pi-openappa # once published
|
|
27
27
|
pi install ./pi-openappa # from a checkout
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
## Smoke test
|
|
31
|
-
|
|
32
|
-
Ship and verify in one pass:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
just deploy # sync the lockfile, run checks, npm publish
|
|
36
|
-
just remove # drop a local-checkout install, if present
|
|
37
|
-
pi install npm:pi-openappa # install the published package
|
|
38
|
-
pi # any session: protection on, appa auto-installs
|
|
39
|
-
appa --version # OK — the runtime is on PATH
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
`pi install` only registers the package — extension code runs when a session
|
|
43
|
-
starts, so `appa` appears after that first session, not before.
|
|
44
|
-
|
|
45
30
|
## Protect sessions
|
|
46
31
|
|
|
47
|
-
Protection is
|
|
48
|
-
out. Opt-outs, most specific first:
|
|
32
|
+
Protection is opt-in, in one of three ways:
|
|
49
33
|
|
|
50
|
-
- **
|
|
51
|
-
|
|
52
|
-
that
|
|
53
|
-
-
|
|
54
|
-
session everywhere runs unguarded until `/appa on`.
|
|
55
|
-
|
|
56
|
-
The project marker `<project>/.pi/openappa` names that project's policy
|
|
57
|
-
(absolute or cwd-relative) and re-enables protection even when globally off;
|
|
58
|
-
empty content falls back to `APPA_CONFIG` or APPA's default:
|
|
34
|
+
- **Project-scoped (recommended):** create `<project>/.pi/openappa` — sessions
|
|
35
|
+
started in that directory are protected, sessions elsewhere are not. The
|
|
36
|
+
marker's optional content names that project's policy (absolute or
|
|
37
|
+
cwd-relative); empty content falls back to `APPA_CONFIG` or APPA's default:
|
|
59
38
|
|
|
60
39
|
```sh
|
|
61
40
|
cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
|
|
62
41
|
```
|
|
63
42
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
43
|
+
- **Built-in:** run `/appa on` once — every Pi session everywhere is
|
|
44
|
+
protected. `/appa off` disables.
|
|
45
|
+
- **Per launch:** `APPA_GATE=1 pi` (the `clappa`-style launcher route).
|
|
46
|
+
|
|
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
|
|
68
54
|
`APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
|
|
69
55
|
refuses with exactly that reason instead of guessing.
|
|
70
56
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
auto-installed.
|
|
76
|
-
|
|
77
|
-
While protected, a runtime that cannot answer blocks the call and the reason
|
|
78
|
-
is returned to the model — **silence never means yes**; `/appa off` always
|
|
79
|
-
works, even with every tool call blocked. One exception: with **no policy
|
|
80
|
-
anywhere** (`APPA_CONFIG`, marker content, and `~/.config/appa/appa.toml` all
|
|
81
|
-
absent) and no runtime answering, the session runs **unprotected** with one
|
|
82
|
-
startup warning naming the fixes — an unconfigured guard must not lock you
|
|
83
|
-
out of your own machine. A named policy that fails stays fail-closed.
|
|
84
|
-
Opted-out sessions never invoke the hook.
|
|
57
|
+
While gated, a runtime that cannot answer blocks the call and the reason is
|
|
58
|
+
returned to the model — **silence never means yes**. If no policy exists the
|
|
59
|
+
startup warning names the exact outs; `/appa off` always works, even with
|
|
60
|
+
every tool call blocked. Ungated sessions never invoke the hook.
|
|
85
61
|
|
|
86
62
|
## Configuration
|
|
87
63
|
|
|
88
|
-
|
|
|
64
|
+
| Source | Default | Meaning |
|
|
89
65
|
|---|---|---|
|
|
90
|
-
| `APPA_GATE` | unset | `1`
|
|
91
|
-
| `.pi/openappa` | absent | Project marker:
|
|
92
|
-
|
|
|
66
|
+
| `APPA_GATE` | unset | `1` protects this session (read once at launch) |
|
|
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` |
|
|
93
69
|
| `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
|
|
94
70
|
| `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
|
|
95
71
|
| `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
|
|
96
|
-
| `APPA_INSTALL_CMD` | `curl -fsSL https://openappa.com/install.sh \| sh` | Auto-install command for a missing default `appa` (pin a mirror or offline copy) |
|
|
97
72
|
| `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
|
|
98
|
-
| `APPA_INSTALL_TIMEOUT_MS` | `120000` | Kill a stuck auto-install after this long |
|
|
99
73
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
|
|
126
|
+
`/appa` reports protection, always-on state, and runtime health; `/appa on`
|
|
127
|
+
and `/appa off` toggle always-on protection (marker:
|
|
128
|
+
`~/.config/pi-openappa/always-on`), taking effect immediately including the
|
|
103
129
|
current session.
|
|
104
130
|
|
|
105
131
|
## Event mapping
|
|
@@ -130,14 +156,7 @@ policies declare them verbatim.
|
|
|
130
156
|
- **Subagents**: spawning is mediated as a plain tool call (deny blocks the
|
|
131
157
|
spawn). Child trajectories are not linked into the parent's label chain
|
|
132
158
|
yet; a gated child Pi process opens its own root trajectory.
|
|
133
|
-
-
|
|
134
|
-
download and run openappa.com's install script once on first run. The
|
|
135
|
-
opt-outs above and `APPA_INSTALL_CMD` are the escapes.
|
|
136
|
-
- **Auto-install is `curl \| sh`**: a protected session with the default
|
|
137
|
-
binary missing downloads and runs the script with user privileges, at most
|
|
138
|
-
once per session start. Pre-install `appa` or pin `APPA_INSTALL_CMD` to
|
|
139
|
-
avoid it.
|
|
140
|
-
- The adapter never starts a runtime for opted-out sessions; auto-start needs
|
|
159
|
+
- The adapter never starts a runtime for ungated sessions; auto-start needs
|
|
141
160
|
either `APPA_CONFIG` or an installed APPA deployment.
|
|
142
161
|
- OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
|
|
143
162
|
entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
|
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,13 +24,14 @@ import {
|
|
|
20
24
|
stopPayload,
|
|
21
25
|
toolResponseFrom,
|
|
22
26
|
} from "../src/adapter.ts";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
27
|
+
import type { InvokeOptions } from "../src/hook-client.ts";
|
|
28
|
+
import { invokeAppaHook } from "../src/hook-client.ts";
|
|
25
29
|
import {
|
|
26
|
-
|
|
30
|
+
appaDefaultConfigPath,
|
|
27
31
|
captureGate,
|
|
28
32
|
checkHealth,
|
|
29
|
-
|
|
33
|
+
setAlwaysOn,
|
|
34
|
+
settingsPath,
|
|
30
35
|
type GateState,
|
|
31
36
|
} from "../src/gate.ts";
|
|
32
37
|
|
|
@@ -35,12 +40,18 @@ interface TextPart {
|
|
|
35
40
|
text: string;
|
|
36
41
|
}
|
|
37
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
|
+
|
|
38
51
|
export default function (pi: ExtensionAPI): void {
|
|
39
52
|
/** Launch-fixed protection state; null until the session starts. */
|
|
40
53
|
let gate: GateState | null = null;
|
|
41
54
|
let sessionId = "";
|
|
42
|
-
/** No policy anywhere and nothing answered: this session runs unprotected. */
|
|
43
|
-
let unprotected = false;
|
|
44
55
|
/**
|
|
45
56
|
* toolCallId → the exact `input` object sent in the PreToolUse payload.
|
|
46
57
|
* PostToolUse must echo it byte-identically or the runtime withholds the
|
|
@@ -48,101 +59,78 @@ export default function (pi: ExtensionAPI): void {
|
|
|
48
59
|
*/
|
|
49
60
|
const pendingInputs = new Map<string, Record<string, unknown>>();
|
|
50
61
|
|
|
51
|
-
|
|
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
|
+
|
|
81
|
+
const gated = (): boolean => gate?.gated === true;
|
|
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
|
+
};
|
|
52
88
|
|
|
53
89
|
pi.on("session_start", async (event, ctx) => {
|
|
54
90
|
gate = captureGate(process.env, ctx.cwd);
|
|
55
91
|
sessionId = ctx.sessionManager.getSessionId();
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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",
|
|
92
|
+
if (!gated()) return;
|
|
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,
|
|
77
104
|
);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.",
|
|
84
114
|
"warning",
|
|
85
115
|
);
|
|
86
|
-
} else {
|
|
87
|
-
const retry = await invokeAppaHook(payload, options);
|
|
88
|
-
if (retry.binaryMissing) {
|
|
89
|
-
warnedAlready = true;
|
|
90
|
-
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.`,
|
|
93
|
-
"warning",
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
outcome = retry;
|
|
97
|
-
}
|
|
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.",
|
|
116
|
-
"warning",
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
return;
|
|
120
116
|
}
|
|
121
|
-
|
|
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
|
-
}
|
|
133
|
-
}
|
|
117
|
+
});
|
|
134
118
|
});
|
|
135
119
|
|
|
136
120
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
137
121
|
if (!gated()) return;
|
|
138
122
|
// The prompt event establishes the turn boundary; it does not gate.
|
|
139
|
-
await
|
|
123
|
+
await enqueue(() =>
|
|
124
|
+
invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd), hookOptions(gate!)),
|
|
125
|
+
);
|
|
140
126
|
});
|
|
141
127
|
|
|
142
128
|
pi.on("tool_call", async (event, ctx) => {
|
|
143
129
|
if (!gated()) return;
|
|
144
130
|
const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
|
|
145
|
-
const outcome = await
|
|
131
|
+
const outcome = await enqueue(() =>
|
|
132
|
+
invokeAppaHook(payload, hookOptions(gate!)),
|
|
133
|
+
);
|
|
146
134
|
const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
|
|
147
135
|
if (decision.type === "deny") {
|
|
148
136
|
return { block: true, reason: `appa: ${decision.reason}` };
|
|
@@ -167,7 +155,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
167
155
|
response,
|
|
168
156
|
ctx.cwd,
|
|
169
157
|
);
|
|
170
|
-
const outcome = await
|
|
158
|
+
const outcome = await enqueue(() =>
|
|
159
|
+
invokeAppaHook(payload, hookOptions(gate!)),
|
|
160
|
+
);
|
|
171
161
|
const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
|
|
172
162
|
if (decision.type === "pass") {
|
|
173
163
|
return undefined;
|
|
@@ -181,7 +171,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
181
171
|
pi.on("turn_end", async () => {
|
|
182
172
|
if (!gated()) return;
|
|
183
173
|
// Turn completion is reported, never gated (matching --turn-end).
|
|
184
|
-
await
|
|
174
|
+
await enqueue(() =>
|
|
175
|
+
invokeAppaHook(stopPayload(sessionId), hookOptions(gate!, { turnEnd: true })),
|
|
176
|
+
);
|
|
185
177
|
});
|
|
186
178
|
|
|
187
179
|
pi.on("session_shutdown", () => {
|
|
@@ -189,41 +181,69 @@ export default function (pi: ExtensionAPI): void {
|
|
|
189
181
|
});
|
|
190
182
|
|
|
191
183
|
pi.registerCommand("appa", {
|
|
192
|
-
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
|
+
},
|
|
193
210
|
handler: async (args, ctx) => {
|
|
194
|
-
const
|
|
211
|
+
const tokens = args.trim().split(/\s+/).filter((token) => token !== "");
|
|
212
|
+
const arg = tokens[0] ?? "";
|
|
213
|
+
|
|
195
214
|
if (arg === "on" || arg === "off") {
|
|
196
|
-
|
|
215
|
+
setAlwaysOn(process.env, arg === "on");
|
|
197
216
|
gate = captureGate(process.env, ctx.cwd);
|
|
198
217
|
if (ctx.hasUI) {
|
|
199
218
|
ctx.ui.notify(
|
|
200
219
|
arg === "on"
|
|
201
|
-
? "OpenAPPA
|
|
202
|
-
: "OpenAPPA
|
|
220
|
+
? "OpenAPPA always-on enabled: every future Pi session is protected."
|
|
221
|
+
: "OpenAPPA always-on disabled.",
|
|
203
222
|
"info",
|
|
204
223
|
);
|
|
205
224
|
}
|
|
206
225
|
return;
|
|
207
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
|
+
|
|
208
235
|
const state = gate ?? captureGate(process.env, ctx.cwd);
|
|
209
236
|
const mode =
|
|
210
237
|
state.source === "project"
|
|
211
238
|
? "project (.pi/openappa)"
|
|
212
|
-
: state.source === "env
|
|
239
|
+
: state.source === "env"
|
|
213
240
|
? "launch (APPA_GATE=1)"
|
|
214
|
-
: state.source === "
|
|
215
|
-
? "
|
|
216
|
-
:
|
|
217
|
-
? "project opt-out (.pi/no-openappa)"
|
|
218
|
-
: state.source === "global-off"
|
|
219
|
-
? "global opt-out (/appa on re-enables)"
|
|
220
|
-
: "on by default (/appa off disables)";
|
|
241
|
+
: state.source === "always-on"
|
|
242
|
+
? "always-on (/appa off to disable)"
|
|
243
|
+
: "off — /appa on enables it for every session";
|
|
221
244
|
const lines: string[] = [];
|
|
222
245
|
lines.push(state.gated ? `Protection: ON (session ${sessionId || "not started"})` : `Protection: off — ${mode}`);
|
|
223
|
-
if (
|
|
224
|
-
lines.push("Session: unprotected — no policy found; see the startup warning.");
|
|
225
|
-
}
|
|
226
|
-
if (state.gated && !unprotected) lines.push(`Mode: ${mode}`);
|
|
246
|
+
if (state.gated) lines.push(`Mode: ${mode}`);
|
|
227
247
|
if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
|
|
228
248
|
lines.push(`Runtime: ${state.runtimeUrl}`);
|
|
229
249
|
const health = await checkHealth(state.runtimeUrl);
|
|
@@ -237,13 +257,59 @@ export default function (pi: ExtensionAPI): void {
|
|
|
237
257
|
}
|
|
238
258
|
},
|
|
239
259
|
});
|
|
240
|
-
}
|
|
241
260
|
|
|
242
|
-
/**
|
|
243
|
-
function
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
313
|
}
|
|
248
314
|
|
|
249
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",
|
|
@@ -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
|
},
|