pi-openappa 0.0.0-stage → 0.1.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 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,121 @@
1
- # Temporary Holding Version
1
+ # pi-openappa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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` ([install](https://openappa.com)) — version
18
+ 0.31.x verified; see `docs/wire-notes.md` for the recorded contract
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
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pi install npm:pi-openappa # once published
26
+ pi install ./pi-openappa # from a checkout
27
+ ```
28
+
29
+ ## Protect sessions
30
+
31
+ Protection is opt-in, in one of three ways:
32
+
33
+ - **Project-scoped (recommended):** create `<project>/.pi/openappa` — sessions
34
+ started in that directory are protected, sessions elsewhere are not. The
35
+ marker's optional content names that project's policy (absolute or
36
+ cwd-relative); empty content falls back to `APPA_CONFIG` or APPA's default:
37
+
38
+ ```sh
39
+ cd your-project && mkdir -p .pi && echo "appa.toml" > .pi/openappa
40
+ ```
41
+
42
+ - **Built-in:** run `/appa on` once — every Pi session everywhere is
43
+ protected. `/appa off` disables.
44
+ - **Per launch:** `APPA_GATE=1 pi` (the `clappa`-style launcher route).
45
+
46
+ A gated session brings the runtime up on its own: `session_start` invokes
47
+ `appa hook --ensure-runtime`, passing `--config "$APPA_CONFIG"` when set and
48
+ otherwise letting APPA use its own default policy (`~/.config/appa/appa.toml`).
49
+ Protected sessions therefore need zero manual server management. A custom
50
+ `APPA_RUNTIME_URL` names a runtime that is *yours* to start — the hook
51
+ refuses with exactly that reason instead of guessing.
52
+
53
+ While gated, a runtime that cannot answer blocks the call and the reason is
54
+ returned to the model — **silence never means yes**. If no policy exists the
55
+ startup warning names the exact outs; `/appa off` always works, even with
56
+ every tool call blocked. Ungated sessions never invoke the hook.
57
+
58
+ ## Configuration
59
+
60
+ | Variable | Default | Meaning |
61
+ |---|---|---|
62
+ | `APPA_GATE` | unset | `1` protects this session (read once at launch) |
63
+ | `.pi/openappa` | absent | Project marker: gates sessions started in that directory; optional content = policy path |
64
+ | `APPA_RUNTIME_URL` | `http://127.0.0.1:8787` | Runtime endpoint (loopback only) |
65
+ | `APPA_CONFIG` | unset | `appa.toml` the session auto-starts the runtime with |
66
+ | `APPA_HOOK_BIN` | `appa` | Hook binary to invoke |
67
+ | `APPA_HOOK_TIMEOUT_MS` | `15000` | Kill the hook after this long; the call is then blocked |
68
+
69
+ `/appa` reports protection, always-on state, and runtime health; `/appa on`
70
+ and `/appa off` toggle always-on protection (marker:
71
+ `~/.config/pi-openappa/always-on`), taking effect immediately including the
72
+ current session.
73
+
74
+ ## Event mapping
75
+
76
+ | Pi event | APPA event | Effect |
77
+ |---|---|---|
78
+ | `session_start` | `SessionStart` | opens the trajectory; warns when the runtime is down |
79
+ | `before_agent_start` | `UserPromptSubmit` | turn boundary (never gates) |
80
+ | `tool_call` | `PreToolUse` | deny blocks the call; the reason reaches the model |
81
+ | `tool_result` | `PostToolUse` | replace swaps the result the model sees |
82
+ | `turn_end` | `Stop` | reported (`--turn-end`), never gates |
83
+
84
+ Pi built-in tool names map to Claude Code policy names (`bash`→`Bash`,
85
+ `find`→`Glob`, …); custom and MCP tools pass through under their own names and
86
+ policies declare them verbatim.
87
+
88
+ ## Limitations
89
+
90
+ - **Load order matters in theory**: the runtime digests tool arguments at
91
+ `PreToolUse`. If another extension rewrites `event.input` after this
92
+ handler runs, the executed arguments differ from the checked ones and the
93
+ runtime withholds the result (`byte_mismatch`). Load pi-openappa last if
94
+ you combine it with argument-rewriting extensions.
95
+ - **Policy edits reach new sessions only**: trajectories keep the policy they
96
+ opened with, by runtime design. When iterating on a policy, restart the
97
+ runtime (and start fresh sessions) — a stale runtime or an old trajectory
98
+ will keep serving the old policy.
99
+ - **Subagents**: spawning is mediated as a plain tool call (deny blocks the
100
+ spawn). Child trajectories are not linked into the parent's label chain
101
+ yet; a gated child Pi process opens its own root trajectory.
102
+ - The adapter never starts a runtime for ungated sessions; auto-start needs
103
+ either `APPA_CONFIG` or an installed APPA deployment.
104
+ - OpenAPPA is Preview & RFC: wire surfaces may break without shims. The
105
+ entire wire contract lives in `src/hook-client.ts` and `src/adapter.ts`
106
+ (verified facts in `docs/wire-notes.md`), and the adapter core is
107
+ runtime-agnostic so a pi-durable wiring can reuse it unchanged.
108
+
109
+ ## Development
110
+
111
+ ```sh
112
+ npm test # node --test, mock-driven, no runtime needed
113
+ npm run typecheck
114
+ ```
115
+
116
+ The adapter core (`src/adapter.ts`) has no Pi imports by design; the Pi
117
+ wiring is `extensions/index.ts` only.
118
+
119
+ ## License
120
+
121
+ 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,183 @@
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 } from "../src/hook-client.ts";
24
+ import { captureGate, checkHealth, setAlwaysOn, type GateState } from "../src/gate.ts";
25
+
26
+ interface TextPart {
27
+ type: "text";
28
+ text: string;
29
+ }
30
+
31
+ export default function (pi: ExtensionAPI): void {
32
+ /** Launch-fixed protection state; null until the session starts. */
33
+ let gate: GateState | null = null;
34
+ let sessionId = "";
35
+ /**
36
+ * toolCallId → the exact `input` object sent in the PreToolUse payload.
37
+ * PostToolUse must echo it byte-identically or the runtime withholds the
38
+ * result (byte_mismatch, see wire notes).
39
+ */
40
+ const pendingInputs = new Map<string, Record<string, unknown>>();
41
+
42
+ const gated = (): boolean => gate?.gated === true;
43
+
44
+ pi.on("session_start", async (event, ctx) => {
45
+ gate = captureGate(process.env, ctx.cwd);
46
+ sessionId = ctx.sessionManager.getSessionId();
47
+ if (!gated()) return;
48
+
49
+ const outcome = await invokeAppaHook(
50
+ sessionStartPayload(sessionId, event.reason, ctx.cwd),
51
+ {
52
+ ensureRuntime: true,
53
+ ...(gate.config !== undefined ? { config: gate.config } : {}),
54
+ },
55
+ );
56
+ if (outcome.exitCode !== 0 && ctx.hasUI) {
57
+ const remedy =
58
+ gate.config === undefined
59
+ ? " Provide a policy (this project's .pi/openappa, APPA_CONFIG, or ~/.config/appa/appa.toml), or run /appa off."
60
+ : "";
61
+ ctx.ui.notify(
62
+ `OpenAPPA gated but the runtime did not answer (${gate.runtimeUrl}): ` +
63
+ `${outcome.stderr.trim() || `exit ${outcome.exitCode}`}.${remedy} ` +
64
+ "Tool calls will be blocked until it answers.",
65
+ "warning",
66
+ );
67
+ }
68
+ });
69
+
70
+ pi.on("before_agent_start", async (event, ctx) => {
71
+ if (!gated()) return;
72
+ // The prompt event establishes the turn boundary; it does not gate.
73
+ await invokeAppaHook(promptPayload(sessionId, event.prompt, ctx.cwd));
74
+ });
75
+
76
+ pi.on("tool_call", async (event, ctx) => {
77
+ if (!gated()) return;
78
+ const payload = preToolUsePayload(sessionId, event.toolName, event.input, ctx.cwd);
79
+ const outcome = await invokeAppaHook(payload);
80
+ const decision = parseCallDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
81
+ if (decision.type === "deny") {
82
+ return { block: true, reason: `appa: ${decision.reason}` };
83
+ }
84
+ pendingInputs.set(event.toolCallId, event.input);
85
+ return undefined;
86
+ });
87
+
88
+ pi.on("tool_result", async (event, ctx) => {
89
+ if (!gated()) return;
90
+ const sentInput = pendingInputs.get(event.toolCallId);
91
+ pendingInputs.delete(event.toolCallId);
92
+ const response = toolResponseFrom({
93
+ text: joinContent(event.content),
94
+ isError: event.isError,
95
+ details: event.details,
96
+ });
97
+ const payload = postToolUsePayload(
98
+ sessionId,
99
+ event.toolName,
100
+ sentInput ?? event.input,
101
+ response,
102
+ ctx.cwd,
103
+ );
104
+ const outcome = await invokeAppaHook(payload);
105
+ const decision = parseResultDecision(outcome.exitCode, outcome.stdout, outcome.stderr);
106
+ if (decision.type === "pass") {
107
+ return undefined;
108
+ }
109
+ return {
110
+ content: [{ type: "text", text: decision.text } satisfies TextPart],
111
+ isError: decision.isError,
112
+ };
113
+ });
114
+
115
+ pi.on("turn_end", async () => {
116
+ if (!gated()) return;
117
+ // Turn completion is reported, never gated (matching --turn-end).
118
+ await invokeAppaHook(stopPayload(sessionId), { turnEnd: true });
119
+ });
120
+
121
+ pi.on("session_shutdown", () => {
122
+ pendingInputs.clear();
123
+ });
124
+
125
+ pi.registerCommand("appa", {
126
+ description: "Show OpenAPPA status; `appa on|off` toggles always-on protection",
127
+ handler: async (args, ctx) => {
128
+ const arg = args.trim();
129
+ if (arg === "on" || arg === "off") {
130
+ setAlwaysOn(process.env, arg === "on");
131
+ gate = captureGate(process.env, ctx.cwd);
132
+ if (ctx.hasUI) {
133
+ ctx.ui.notify(
134
+ arg === "on"
135
+ ? "OpenAPPA always-on enabled: every future Pi session is protected."
136
+ : "OpenAPPA always-on disabled.",
137
+ "info",
138
+ );
139
+ }
140
+ return;
141
+ }
142
+ const state = gate ?? captureGate(process.env, ctx.cwd);
143
+ const mode =
144
+ state.source === "project"
145
+ ? "project (.pi/openappa)"
146
+ : state.source === "env"
147
+ ? "launch (APPA_GATE=1)"
148
+ : state.source === "always-on"
149
+ ? "always-on (/appa off to disable)"
150
+ : "off — /appa on enables it for every session";
151
+ const lines: string[] = [];
152
+ lines.push(state.gated ? `Protection: ON (session ${sessionId || "not started"})` : `Protection: off — ${mode}`);
153
+ if (state.gated) lines.push(`Mode: ${mode}`);
154
+ if (state.config !== undefined) lines.push(`Policy: ${state.config}`);
155
+ lines.push(`Runtime: ${state.runtimeUrl}`);
156
+ const health = await checkHealth(state.runtimeUrl);
157
+ lines.push(`Health: ${health.ok ? "ok" : `unreachable (${health.detail})`}`);
158
+ if (state.gated && !health.ok) {
159
+ lines.push("While the runtime is down, gated tool calls are blocked (fail-closed).");
160
+ }
161
+ const message = lines.join("\n");
162
+ if (ctx.hasUI) {
163
+ ctx.ui.notify(message, health.ok || !state.gated ? "info" : "warning");
164
+ }
165
+ },
166
+ });
167
+ }
168
+
169
+ function joinContent(content: ReadonlyArray<unknown>): string {
170
+ const parts: string[] = [];
171
+ for (const item of content) {
172
+ if (
173
+ typeof item === "object" &&
174
+ item !== null &&
175
+ (item as { type?: unknown }).type === "text"
176
+ ) {
177
+ parts.push(String((item as { text?: unknown }).text ?? ""));
178
+ } else {
179
+ parts.push("[non-text content omitted]");
180
+ }
181
+ }
182
+ return parts.join("\n");
183
+ }
package/justfile ADDED
@@ -0,0 +1,32 @@
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
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "pi-openappa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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
+ "type": "module",
15
+ "pi": {
16
+ "extensions": ["./extensions/index.ts"]
17
+ },
18
+ "main": "./extensions/index.ts",
19
+ "scripts": {
20
+ "test": "node --test 'test/*.test.ts'",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*"
25
+ },
26
+ "devDependencies": {
27
+ "@earendil-works/pi-coding-agent": "^1.0.4",
28
+ "typescript": "^5.9.3"
29
+ }
30
+ }