pi-openappa 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +158 -2
- package/docs/wire-notes.md +80 -0
- package/extensions/index.ts +263 -0
- package/justfile +39 -0
- package/package.json +35 -4
- package/src/adapter.ts +284 -0
- package/src/gate.ts +188 -0
- package/src/hook-client.ts +138 -0
- package/src/installer.ts +103 -0
- package/test/adapter.test.ts +207 -0
- package/test/auto-install.test.ts +85 -0
- package/test/extension.test.ts +667 -0
- package/test/fixtures/mock-appa.mjs +104 -0
- package/test/gate.test.ts +131 -0
- package/tsconfig.json +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aemonge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,159 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-openappa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A thin [OpenAPPA](https://openappa.com) guard extension for
|
|
4
|
+
[Pi](https://pi.dev): every tool call in a protected session is checked by the
|
|
5
|
+
APPA runtime before it runs, and every tool result before the model sees it.
|
|
6
|
+
No policy logic lives here — the APPA runtime owns every decision; this
|
|
7
|
+
extension only translates events and enforces the answer.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
pi event ──▶ adapter ──▶ `appa hook` ──▶ APPA runtime
|
|
11
|
+
allow / deny / replace
|
|
12
|
+
pi event ◀── enforce ◀── decision ◀────────────┘
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- The `appa` binary on `PATH`: `curl -fsSL https://openappa.com/install.sh | sh`
|
|
18
|
+
(the extension installs it automatically when missing) — version 0.31.x
|
|
19
|
+
verified; see `docs/wire-notes.md` for the recorded contract
|
|
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
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pi install npm:pi-openappa # published on npm; indexed by the Pi gallery
|
|
27
|
+
pi install ./pi-openappa # from a checkout
|
|
28
|
+
```
|
|
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
|
+
## Protect sessions
|
|
46
|
+
|
|
47
|
+
Protection is **on by default**: every Pi session is guarded unless you opt
|
|
48
|
+
out. Opt-outs, most specific first:
|
|
49
|
+
|
|
50
|
+
- **Per launch:** `APPA_GATE=0 pi` (and `APPA_GATE=1 pi` to force it on).
|
|
51
|
+
- **Per project:** create `<project>/.pi/no-openappa` — sessions started in
|
|
52
|
+
that directory run unguarded.
|
|
53
|
+
- **Globally:** `/appa off` once (marker `~/.config/pi-openappa/off`) — every
|
|
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:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A protected session brings the runtime up on its own: `session_start` invokes
|
|
65
|
+
`appa hook --ensure-runtime`, passing `--config "$APPA_CONFIG"` when set and
|
|
66
|
+
otherwise letting APPA use its own default policy (`~/.config/appa/appa.toml`).
|
|
67
|
+
Protected sessions therefore need zero manual server management. A custom
|
|
68
|
+
`APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
|
|
69
|
+
refuses with exactly that reason instead of guessing.
|
|
70
|
+
|
|
71
|
+
When the default `appa` is missing from `PATH`, a protected session installs
|
|
72
|
+
it itself — once, with a UI notice — by running the same official script
|
|
73
|
+
(`curl -fsSL https://openappa.com/install.sh | sh`), then retries starting
|
|
74
|
+
the runtime. Sessions that name a custom `APPA_HOOK_BIN` are never
|
|
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.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
| Variable | Default | Meaning |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `APPA_GATE` | unset | `1` forces protection on for this launch; `0` forces it off |
|
|
91
|
+
| `.pi/openappa` | absent | Project marker: names that project's policy and forces protection on |
|
|
92
|
+
| `.pi/no-openappa` | absent | Project opt-out: sessions started there run unguarded |
|
|
93
|
+
| `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
|
|
94
|
+
| `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
|
|
95
|
+
| `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
|
+
| `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
|
+
|
|
100
|
+
`/appa` reports protection, opt-out state, and runtime health; `/appa off`
|
|
101
|
+
disables protection globally (marker `~/.config/pi-openappa/off`) and
|
|
102
|
+
`/appa on` re-enables it, taking effect immediately including the
|
|
103
|
+
current session.
|
|
104
|
+
|
|
105
|
+
## Event mapping
|
|
106
|
+
|
|
107
|
+
| Pi event | APPA event | Effect |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `session_start` | `SessionStart` | opens the trajectory; warns when the runtime is down |
|
|
110
|
+
| `before_agent_start` | `UserPromptSubmit` | turn boundary (never gates) |
|
|
111
|
+
| `tool_call` | `PreToolUse` | deny blocks the call; the reason reaches the model |
|
|
112
|
+
| `tool_result` | `PostToolUse` | replace swaps the result the model sees |
|
|
113
|
+
| `turn_end` | `Stop` | reported (`--turn-end`), never gates |
|
|
114
|
+
|
|
115
|
+
Pi built-in tool names map to Claude Code policy names (`bash`→`Bash`,
|
|
116
|
+
`find`→`Glob`, …); custom and MCP tools pass through under their own names and
|
|
117
|
+
policies declare them verbatim.
|
|
118
|
+
|
|
119
|
+
## Limitations
|
|
120
|
+
|
|
121
|
+
- **Load order matters in theory**: the runtime digests tool arguments at
|
|
122
|
+
`PreToolUse`. If another extension rewrites `event.input` after this
|
|
123
|
+
handler runs, the executed arguments differ from the checked ones and the
|
|
124
|
+
runtime withholds the result (`byte_mismatch`). Load pi-openappa last if
|
|
125
|
+
you combine it with argument-rewriting extensions.
|
|
126
|
+
- **Policy edits reach new sessions only**: trajectories keep the policy they
|
|
127
|
+
opened with, by runtime design. When iterating on a policy, restart the
|
|
128
|
+
runtime (and start fresh sessions) — a stale runtime or an old trajectory
|
|
129
|
+
will keep serving the old policy.
|
|
130
|
+
- **Subagents**: spawning is mediated as a plain tool call (deny blocks the
|
|
131
|
+
spawn). Child trajectories are not linked into the parent's label chain
|
|
132
|
+
yet; a gated child Pi process opens its own root trajectory.
|
|
133
|
+
- **On by default**: installing this extension guards every session and may
|
|
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
|
|
141
|
+
either `APPA_CONFIG` or an installed APPA deployment.
|
|
142
|
+
- OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
|
|
143
|
+
entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
|
|
144
|
+
(verified facts in `docs/wire-notes.md`), and the adapter core is
|
|
145
|
+
runtime-agnostic so a pi-durable wiring can reuse it unchanged.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
npm test # node --test, mock-driven, no runtime needed
|
|
151
|
+
npm run typecheck
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The adapter core (`src/adapter.ts`) has no Pi imports by design; the Pi
|
|
155
|
+
wiring is `extensions/index.ts` only.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Wire notes: `appa hook` contract (verified 2026-10-06, appa 0.31.1)
|
|
2
|
+
|
|
3
|
+
All facts below were probed live against `appa runtime` on 127.0.0.1:8791 with a
|
|
4
|
+
minimal one-tool policy. Probes: SessionStart, UserPromptSubmit, PreToolUse (allow,
|
|
5
|
+
undeclared-refuse), PostToolUse (byte-mismatch withhold), Stop --turn-end, plus
|
|
6
|
+
ungated and runtime-down probes.
|
|
7
|
+
|
|
8
|
+
## Transport
|
|
9
|
+
|
|
10
|
+
- Subprocess: `appa hook` (env inherited; gate and URL read from env)
|
|
11
|
+
- Gate: inert unless `APPA_GATE=1` — ungated always exits 0, no side effects
|
|
12
|
+
- URL: `APPA_RUNTIME_URL` (default `127.0.0.1:8787`, loopback only)
|
|
13
|
+
- Turn-end: pass `--turn-end` (non-blocking report; exit 0 even when down)
|
|
14
|
+
- Payload: Claude Code hook JSON on stdin
|
|
15
|
+
|
|
16
|
+
## Payload fields (accepted and observed)
|
|
17
|
+
|
|
18
|
+
- `session_id` — becomes trajectory `cc:<session_id>` in the runtime
|
|
19
|
+
- `hook_event_name` — `SessionStart` | `UserPromptSubmit` | `PreToolUse` |
|
|
20
|
+
`PostToolUse` | `Stop`
|
|
21
|
+
- `source` (SessionStart): `startup` | `resume`
|
|
22
|
+
- `prompt` (UserPromptSubmit)
|
|
23
|
+
- `tool_name`, `tool_input` (PreToolUse / PostToolUse)
|
|
24
|
+
- `tool_response` (PostToolUse)
|
|
25
|
+
- `cwd`
|
|
26
|
+
|
|
27
|
+
## Decision protocol (observed)
|
|
28
|
+
|
|
29
|
+
- Exit 0, no stdout (or `{}`) → proceed. SessionStart/UserPromptSubmit behave so.
|
|
30
|
+
- Exit 0 + `{"hookSpecificOutput":{"hookEventName":"PreToolUse",
|
|
31
|
+
"permissionDecision":"allow","permissionDecisionReason":"..."}}` → proceed.
|
|
32
|
+
- Exit 2 + stderr `OpenAPPA hook blocked: <reason>` → block; reason goes to the
|
|
33
|
+
model. Observed for an undeclared tool (HTTP 409 upstream, stdout carries
|
|
34
|
+
`{"error": "<reason>"}`).
|
|
35
|
+
- Exit 0 + `{"decision":"block","reason":"...",
|
|
36
|
+
"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{...}}}`
|
|
37
|
+
→ PostToolUse replacement: `updatedToolOutput` is what the model must see
|
|
38
|
+
(observed: withheld-result notice replacing a mismatched result).
|
|
39
|
+
- Runtime down: PreToolUse exit 2 (fail-closed, stderr names 127.0.0.1:8787);
|
|
40
|
+
Stop --turn-end exit 0 + stderr warning.
|
|
41
|
+
|
|
42
|
+
## Hard constraints
|
|
43
|
+
|
|
44
|
+
1. **Byte-exact echo**: PostToolUse `tool_input` must be byte-identical to the
|
|
45
|
+
PreToolUse `tool_input` (runtime digests it canonically; mismatch → result
|
|
46
|
+
withheld with `byte_mismatch`). The extension must remember the exact object
|
|
47
|
+
it sent per tool call id.
|
|
48
|
+
2. **Tool naming**: the runtime sees `host/claude-code/<Name>`; policies are
|
|
49
|
+
written in Claude-Code naming. Map pi built-ins: bash→Bash,
|
|
50
|
+
powershell→PowerShell, read→Read, edit→Edit, write→Write, grep→Grep,
|
|
51
|
+
find→Glob, ls→LS. Unknown/custom tools pass through verbatim; policy authors
|
|
52
|
+
declare them under the pi name.
|
|
53
|
+
3. **tool_response shape**: Claude-Code-shaped for tools where pi provides the
|
|
54
|
+
data (bash: `stdout`/`stderr`/`exitcode`/`interrupted` from details when
|
|
55
|
+
present); otherwise generic `{ "output": <joined text>, "isError": <bool> }`.
|
|
56
|
+
|
|
57
|
+
## Session mapping
|
|
58
|
+
|
|
59
|
+
pi `session_start.reason`: `startup|new` → `startup`; `resume|fork|reload` →
|
|
60
|
+
`resume`.
|
|
61
|
+
|
|
62
|
+
## Not yet observed (client parses defensively)
|
|
63
|
+
|
|
64
|
+
- `permissionDecision: "deny"` exact shape (a declared-but-denied call)
|
|
65
|
+
- `deliver_value` / remedy offers on PreToolUse
|
|
66
|
+
- Subagent (`SubagentStop`, child trajectories) — out of v1 scope
|
|
67
|
+
|
|
68
|
+
## Auto-start (verified 2026-10-06)
|
|
69
|
+
|
|
70
|
+
- `appa hook --ensure-runtime --config <path>` with nothing on the default
|
|
71
|
+
port boots a runtime on `127.0.0.1:8787`; the hook exits 0.
|
|
72
|
+
- `--ensure-runtime` with a custom `APPA_RUNTIME_URL` exits 2:
|
|
73
|
+
`the runtime could not be started: nothing answers <url>, and a runtime at
|
|
74
|
+
a URL the session named is the user's own to start` — custom URLs are
|
|
75
|
+
user-managed by design; nothing binds 8787 in that case.
|
|
76
|
+
|
|
77
|
+
## Test seam
|
|
78
|
+
|
|
79
|
+
`appa replay <dir>` checks `.appa` trace files against a policy; mock-driven unit
|
|
80
|
+
tests remain our primary seam (no runtime needed).
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-openappa — Pi extension wiring for OpenAPPA.
|
|
3
|
+
*
|
|
4
|
+
* Thin by design: translate Pi events to `appa hook` invocations and enforce
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Wire facts and constraints: docs/wire-notes.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
parseCallDecision,
|
|
15
|
+
parseResultDecision,
|
|
16
|
+
postToolUsePayload,
|
|
17
|
+
preToolUsePayload,
|
|
18
|
+
promptPayload,
|
|
19
|
+
sessionStartPayload,
|
|
20
|
+
stopPayload,
|
|
21
|
+
toolResponseFrom,
|
|
22
|
+
} from "../src/adapter.ts";
|
|
23
|
+
import { invokeAppaHook, resolveHookBin, type HookOutcome } from "../src/hook-client.ts";
|
|
24
|
+
import { installAppa, type InstallOutcome } from "../src/installer.ts";
|
|
25
|
+
import {
|
|
26
|
+
appaDefaultPolicyExists,
|
|
27
|
+
captureGate,
|
|
28
|
+
checkHealth,
|
|
29
|
+
setGloballyOff,
|
|
30
|
+
type GateState,
|
|
31
|
+
} from "../src/gate.ts";
|
|
32
|
+
|
|
33
|
+
interface TextPart {
|
|
34
|
+
type: "text";
|
|
35
|
+
text: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function (pi: ExtensionAPI): void {
|
|
39
|
+
/** Launch-fixed protection state; null until the session starts. */
|
|
40
|
+
let gate: GateState | null = null;
|
|
41
|
+
let sessionId = "";
|
|
42
|
+
/** No policy anywhere and nothing answered: this session runs unprotected. */
|
|
43
|
+
let unprotected = false;
|
|
44
|
+
/**
|
|
45
|
+
* toolCallId → the exact `input` object sent in the PreToolUse payload.
|
|
46
|
+
* PostToolUse must echo it byte-identically or the runtime withholds the
|
|
47
|
+
* result (byte_mismatch, see wire notes).
|
|
48
|
+
*/
|
|
49
|
+
const pendingInputs = new Map<string, Record<string, unknown>>();
|
|
50
|
+
|
|
51
|
+
const gated = (): boolean => gate?.gated === true && !unprotected;
|
|
52
|
+
|
|
53
|
+
pi.on("session_start", async (event, ctx) => {
|
|
54
|
+
gate = captureGate(process.env, ctx.cwd);
|
|
55
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
56
|
+
if (!gate.gated) return;
|
|
57
|
+
unprotected = false;
|
|
58
|
+
|
|
59
|
+
const payload = sessionStartPayload(sessionId, event.reason, ctx.cwd);
|
|
60
|
+
const options = {
|
|
61
|
+
ensureRuntime: true,
|
|
62
|
+
...(gate.config !== undefined ? { config: gate.config } : {}),
|
|
63
|
+
};
|
|
64
|
+
const notify = (message: string, kind: "info" | "warning"): void => {
|
|
65
|
+
if (ctx.hasUI) ctx.ui.notify(message, kind);
|
|
66
|
+
};
|
|
67
|
+
let outcome = await invokeAppaHook(payload, options);
|
|
68
|
+
let warnedAlready = false;
|
|
69
|
+
// A missing default `appa` is self-provisioned: run the official install
|
|
70
|
+
// script once, then retry bringing the runtime up. Custom APPA_HOOK_BINs
|
|
71
|
+
// are the user's own and never auto-installed.
|
|
72
|
+
if (outcome.binaryMissing && resolveHookBin(process.env) === "appa") {
|
|
73
|
+
notify(
|
|
74
|
+
"`appa` was not found — installing the OpenAPPA runtime " +
|
|
75
|
+
"(https://openappa.com/install.sh)…",
|
|
76
|
+
"info",
|
|
77
|
+
);
|
|
78
|
+
const install = await installAppa();
|
|
79
|
+
if (install.exitCode !== 0) {
|
|
80
|
+
warnedAlready = true;
|
|
81
|
+
notify(
|
|
82
|
+
`OpenAPPA auto-install failed: ${outcomeTail(install)}. ` +
|
|
83
|
+
"Tool calls stay blocked; install `appa` manually (see the README) or run /appa off.",
|
|
84
|
+
"warning",
|
|
85
|
+
);
|
|
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
|
+
}
|
|
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
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
137
|
+
if (!gated()) return;
|
|
138
|
+
// The prompt event establishes the turn boundary; it does not gate.
|
|
139
|
+
await invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
143
|
+
if (!gated()) return;
|
|
144
|
+
const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
|
|
145
|
+
const outcome = await invokeAppaHook(payload);
|
|
146
|
+
const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
|
|
147
|
+
if (decision.type === "deny") {
|
|
148
|
+
return { block: true, reason: `appa: ${decision.reason}` };
|
|
149
|
+
}
|
|
150
|
+
pendingInputs.set(event.toolCallId, event.input);
|
|
151
|
+
return undefined;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
155
|
+
if (!gated()) return;
|
|
156
|
+
const sentInput = pendingInputs.get(event.toolCallId);
|
|
157
|
+
pendingInputs.delete(event.toolCallId);
|
|
158
|
+
const response = toolResponseFrom({
|
|
159
|
+
text: joinContent(event.content),
|
|
160
|
+
isError: event.isError,
|
|
161
|
+
details: event.details,
|
|
162
|
+
});
|
|
163
|
+
const payload = postToolUsePayload(
|
|
164
|
+
sessionId,
|
|
165
|
+
event.toolName,
|
|
166
|
+
sentInput ?? event.input,
|
|
167
|
+
response,
|
|
168
|
+
ctx.cwd,
|
|
169
|
+
);
|
|
170
|
+
const outcome = await invokeAppaHook(payload);
|
|
171
|
+
const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
|
|
172
|
+
if (decision.type === "pass") {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: decision.text } satisfies TextPart],
|
|
177
|
+
isError: decision.isError,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pi.on("turn_end", async () => {
|
|
182
|
+
if (!gated()) return;
|
|
183
|
+
// Turn completion is reported, never gated (matching --turn-end).
|
|
184
|
+
await invokeAppaHook(stopPayload(sessionId), { turnEnd: true });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
pi.on("session_shutdown", () => {
|
|
188
|
+
pendingInputs.clear();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
pi.registerCommand("appa", {
|
|
192
|
+
description: "Show OpenAPPA status; `appa on|off` toggles protection globally",
|
|
193
|
+
handler: async (args, ctx) => {
|
|
194
|
+
const arg = args.trim();
|
|
195
|
+
if (arg === "on" || arg === "off") {
|
|
196
|
+
setGloballyOff(process.env, arg === "off");
|
|
197
|
+
gate = captureGate(process.env, ctx.cwd);
|
|
198
|
+
if (ctx.hasUI) {
|
|
199
|
+
ctx.ui.notify(
|
|
200
|
+
arg === "on"
|
|
201
|
+
? "OpenAPPA protection re-enabled: on by default for every session."
|
|
202
|
+
: "OpenAPPA protection disabled globally (/appa on re-enables).",
|
|
203
|
+
"info",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const state = gate ?? captureGate(process.env, ctx.cwd);
|
|
209
|
+
const mode =
|
|
210
|
+
state.source === "project"
|
|
211
|
+
? "project (.pi/openappa)"
|
|
212
|
+
: state.source === "env-on"
|
|
213
|
+
? "launch (APPA_GATE=1)"
|
|
214
|
+
: state.source === "env-off"
|
|
215
|
+
? "launch opt-out (APPA_GATE=0)"
|
|
216
|
+
: state.source === "project-off"
|
|
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)";
|
|
221
|
+
const lines: string[] = [];
|
|
222
|
+
lines.push(state.gated ? `Protection: ON (session ${sessionId || "not started"})` : `Protection: off — ${mode}`);
|
|
223
|
+
if (unprotected) {
|
|
224
|
+
lines.push("Session: unprotected — no policy found; see the startup warning.");
|
|
225
|
+
}
|
|
226
|
+
if (state.gated && !unprotected) lines.push(`Mode: ${mode}`);
|
|
227
|
+
if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
|
|
228
|
+
lines.push(`Runtime: ${state.runtimeUrl}`);
|
|
229
|
+
const health = await checkHealth(state.runtimeUrl);
|
|
230
|
+
lines.push(`Health: ${health.ok ? "ok" : `unreachable (${health.detail})`}`);
|
|
231
|
+
if (state.gated && !health.ok) {
|
|
232
|
+
lines.push("While the runtime is down, gated tool calls are blocked (fail-closed).");
|
|
233
|
+
}
|
|
234
|
+
const message = lines.join("\n");
|
|
235
|
+
if (ctx.hasUI) {
|
|
236
|
+
ctx.ui.notify(message, health.ok || !state.gated ? "info" : "warning");
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Last ~200 chars of installer output, whitespace-normalized, for notices. */
|
|
243
|
+
function outcomeTail(outcome: InstallOutcome): string {
|
|
244
|
+
const text = `${outcome.stderr} ${outcome.stdout}`.trim().replace(/\s+/g, " ");
|
|
245
|
+
if (text === "") return "(no output)";
|
|
246
|
+
return text.length > 200 ? `…${text.slice(-200)}` : text;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function joinContent(content: ReadonlyArray<unknown>): string {
|
|
250
|
+
const parts: string[] = [];
|
|
251
|
+
for (const item of content) {
|
|
252
|
+
if (
|
|
253
|
+
typeof item === "object" &&
|
|
254
|
+
item !== null &&
|
|
255
|
+
(item as { type?: unknown }).type === "text"
|
|
256
|
+
) {
|
|
257
|
+
parts.push(String((item as { text?: unknown }).text ?? ""));
|
|
258
|
+
} else {
|
|
259
|
+
parts.push("[non-text content omitted]");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return parts.join("\n");
|
|
263
|
+
}
|
package/justfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# pi-openappa — a Pi extension for OpenAPPA
|
|
2
|
+
# Just runs from the repo root; recipes are boring on purpose.
|
|
3
|
+
|
|
4
|
+
# Run everything a change should pass (default)
|
|
5
|
+
default: check
|
|
6
|
+
|
|
7
|
+
# Tests + typecheck together
|
|
8
|
+
check: test typecheck
|
|
9
|
+
|
|
10
|
+
# Mock-driven test suite (no APPA runtime needed)
|
|
11
|
+
test:
|
|
12
|
+
npm test
|
|
13
|
+
|
|
14
|
+
# TypeScript check, no emit
|
|
15
|
+
typecheck:
|
|
16
|
+
npm run typecheck
|
|
17
|
+
|
|
18
|
+
# Install this checkout as a local Pi package (loads live from this path)
|
|
19
|
+
install:
|
|
20
|
+
pi install {{justfile_directory()}}
|
|
21
|
+
|
|
22
|
+
# Reconcile Pi package installations after dependency changes
|
|
23
|
+
update:
|
|
24
|
+
pi update --extensions
|
|
25
|
+
|
|
26
|
+
# Remove this checkout from Pi's packages
|
|
27
|
+
remove:
|
|
28
|
+
pi remove {{justfile_directory()}}
|
|
29
|
+
|
|
30
|
+
# Publish to npm (requires `npm login`; the Pi gallery indexes the pi-package keyword)
|
|
31
|
+
publish: check
|
|
32
|
+
npm publish
|
|
33
|
+
|
|
34
|
+
# Ship a version: sync the lockfile version, run every check, publish to npm
|
|
35
|
+
# (run the README smoke test right after)
|
|
36
|
+
deploy:
|
|
37
|
+
npm install --package-lock-only --no-audit --no-fund
|
|
38
|
+
just check
|
|
39
|
+
npm publish
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openappa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Thin OpenAPPA guard extension for Pi: gates tool calls through the APPA runtime. No policy logic lives here.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"openappa",
|
|
8
|
+
"appa",
|
|
9
|
+
"security",
|
|
10
|
+
"guardrails",
|
|
11
|
+
"policy"
|
|
12
|
+
],
|
|
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",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": ["./extensions/index.ts"]
|
|
24
|
+
},
|
|
25
|
+
"main": "./extensions/index.ts",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test 'test/*.test.ts'",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^1.0.4",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|