Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ How to keep this current: add the entry in the same pull request as the change,

<!-- Empty. Next release starts here. -->

## 0.74.0

### Added

- `typesafeBackend` accepts `"commandcode"`: judgments go to api.commandcode.ai under `/provider/v1/systemone`, with the key from `COMMANDCODE_API_KEY` and the model `typesafe/jev`.
- `typesafeBackend` accepts a caller-supplied endpoint object (`{ "label", "host", "path", "keyEnv", "defaultModel" }`, plus optional model-list fields) for a gateway that serves the same decisions protocol (pi-typesafe 0.8.0). The object is passed to the judge as written and validated on every call. It reads only its own `keyEnv` variable: the TypeSafe key and the `/typesafe login` store are never sent to it. `/warden status`, the `/warden enable` dialog, and the `/warden test` confirmation name the label, host, and model sent for any non-TypeSafe backend, and the consent disclosure names the real destination host.

### Changed

- An unknown `typesafeBackend` name, or an endpoint object the judge refuses, no longer silently falls back to `"typesafe"`: judgments turn off, the refusal message is shown once and in `/warden status`, and nothing is sent to api.typesafe.ai because a value was mistyped.

## 0.73.1

### Tests
Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). `/warden config` ope
| --- | --- |
| `enabled` | Master switch for the extension. |
| `typesafe` | Consent to send requests to TypeSafe. Set by `/warden enable`; only the user file or `PI_WARDEN_ENABLED=1` can grant it. |
| `typesafeBackend` | The judgment service: `"typesafe"` (default) or `"openrouter"`. User file only — a project must not redirect judgments. |
| `typesafeBackend` | Where the judgments go: `"typesafe"` (default, api.typesafe.ai), `"openrouter"` (openrouter.ai), `"commandcode"` (api.commandcode.ai), or an endpoint object for a gateway that serves the same decisions protocol: `{ "label": "Corp judge gateway", "host": "https://gw.example.com", "path": "/judge/systemone", "keyEnv": "GW_JUDGE_KEY", "defaultModel": "jev-1.13" }`. `keyEnv` is required, must not be `TYPESAFE_API_KEY`, and names the environment variable that carries the endpoint's key: the TypeSafe key and the `/typesafe login` store are never sent to it. `path`, `defaultModel`, and the model-list fields `modelsPath`, `modelsField`, `modelsIdField`, `modelsVerifyKey` are optional; the host must be `https:` (`http:` only for a loopback host). User file only — a project must not redirect judgments. Nothing silently falls back: an unknown name or an object the judge refuses turns judgments off, and the refusal message is shown once and in `/warden status`. |
| `mode` | `steer` (hold goes back to the agent), `confirm` (dialog for you), `advise` (never holds). User file only; a project's `.pi/pi-warden.json` cannot change it, by design. |
| `timeoutMs` | Per-request timeout. On timeout the call is allowed with a warning when `action.failOpen` is true. |
| `maxRequests` | Per-session request budget. When spent, pi-warden says so once and continues with offline checks. |
Expand Down Expand Up @@ -263,12 +263,13 @@ It is a backstop for sanctioned work, not a boundary anyone hostile respects: ke
| --- | --- |
| `TYPESAFE_API_KEY` | Takes precedence over the key stored by `/warden enable` or `/typesafe login`. |
| `OPENROUTER_API_KEY` | API key for the OpenRouter backend. Required when `typesafeBackend` is `"openrouter"`. |
| `COMMANDCODE_API_KEY` | API key for the Command Code backend. Required when `typesafeBackend` is `"commandcode"`. |
| `PI_WARDEN_ENABLED=1` | Grants consent for headless runs (same as `"typesafe": true`). |
| `PI_WARDEN_MODE=steer\|confirm\|advise` | Overrides `mode`. |
| `PI_WARDEN_TRACE_DIR=<absolute path>` | Appends the trace to `<path>/<session id>.jsonl`, one file per Pi session. For a host that runs Pi in RPC mode, where the status line and the sidebar never show. An empty or relative path turns it off. See [Trace file](#trace-file). |
| `PI_WARDEN_HOST_PATHS=<dir>:<dir>` | Directories outside the project where the host lets its agent write, `:`-separated. A `write` or `edit` in one of them, after `..` and symlinks are resolved, is not held or warned by the outside-project rule. Every other check still applies: command rules, path rules, deny rules, sensitive paths, secrets, and Jev; the action summary still shows the path as outside the project, and the rules guard still does not judge these files against the project rules. Relative entries, empty entries, and `/` are ignored. Read once per session. Environment only: no config file can set or extend it. pi-warden's index directory (`pi-warden/index/` under Pi's agent directory), where `/warden index` asks the agent to write, is always treated as a host path; the rest of the agent directory is not. |

When a guard would ask Jev but cannot, Warden tells you once per session and reason why, and what to do: no consent (`/warden enable`, or `PI_WARDEN_ENABLED=1` headless), no key for the backend (set its key variable, or run `/typesafe login` for the TypeSafe backend), or a rejected key (named by where it comes from: run `/typesafe login` again for the key it saved, or check the key in the environment variable and run `/warden status`); a headless session gets a status message instead of a notice, and a spent request budget keeps its own warning.
When a guard would ask Jev but cannot, Warden tells you once per session and reason why, and what to do: no consent (`/warden enable`, or `PI_WARDEN_ENABLED=1` headless), a refused `typesafeBackend` (the refusal message, quoted; fix the value in the user config), no key for the backend (set its key variable, or run `/typesafe login` for the TypeSafe backend), or a rejected key (named by where it comes from: run `/typesafe login` again for the key it saved, or check the key in the environment variable and run `/warden status`); a headless session gets a status message instead of a notice, and a spent request budget keeps its own warning.

### Trace file

Expand Down
2 changes: 1 addition & 1 deletion docs/data-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ What pi-warden sends to TypeSafe, what it keeps on this machine, and what it nev

## What is sent, per guard

With consent, requests go to `https://api.typesafe.ai` (default) or `https://openrouter.ai` when `typesafeBackend` is set to `"openrouter"` in the user config.
With consent, requests go to `https://api.typesafe.ai` (default), or to the host `typesafeBackend` names in the user config: `https://openrouter.ai` for `"openrouter"`, `https://api.commandcode.ai` for `"commandcode"`, or the `host` of a caller-supplied endpoint object. `/warden status`, the `/warden enable` dialog, and the `/warden test` confirmation name the destination host — and for a non-TypeSafe backend the label, host, and model sent — so who answers is always visible before anything leaves. An endpoint object never receives the TypeSafe key or the `/typesafe login` store; it gets only the key from the environment variable its `keyEnv` names.

| Guard | Sent |
| --- | --- |
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-warden",
"version": "0.73.1",
"version": "0.74.0",
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -63,8 +63,8 @@
"eval:judge": "node scripts/judge-bench.mjs",
"eval:replay": "node scripts/rules-replay.mjs",
"eval:rules": "node scripts/rules-bench.mjs",
"eval:replay": "node scripts/rules-replay.mjs",
"eval:rules-turn": "node scripts/rules-turn-bench.mjs" },
"eval:rules-turn": "node scripts/rules-turn-bench.mjs"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.85.1 <1",
"@earendil-works/pi-tui": ">=0.85.1 <1"
Expand All @@ -85,6 +85,6 @@
"typescript": "^6.0.0"
},
"dependencies": {
"pi-typesafe": "^0.7.0"
"pi-typesafe": "^0.8.0"
}
}
68 changes: 46 additions & 22 deletions src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,59 @@
import type { TypeSafeOptions } from "pi-typesafe";
import { DECISIONS_BACKENDS, DEFAULT_BACKEND } from "pi-typesafe";
import type { BackendSpec, TypeSafeOptions } from "pi-typesafe";
import { DEFAULT_BACKEND, backendHost, resolveBackend } from "pi-typesafe";

/** The judgment backend that receives pi-warden's Jev requests. */
export type JudgmentBackend = "typesafe" | "openrouter";
/** The judgment backend: a registry name ("typesafe", "openrouter", "commandcode") or a caller-supplied endpoint object. */
export type JudgmentBackend = BackendSpec;

/** Why no judge is available: consent not given, no key for the backend, a saved 401 or 403, or the request budget spent. */
export type JudgmentsOffReason = "no_consent" | "no_key" | "key_rejected" | "budget";
/** The judgment destination as the config carries it, plus the refusal that turns judgments off when the configured value was refused. */
export interface BackendSetting {
/** The spec judgments go to; undefined only when the configured value was refused. */
readonly typesafeBackend: JudgmentBackend | undefined;
/** Why the configured value was refused: the once-per-session notice and `/warden status` quote it. */
readonly backendRefusal: string | undefined;
}

/** What a raw `typesafeBackend` value resolves to: the spec judgments go to, or the refusal that turns them off. Exactly one is set. */
export type BackendResolution =
| { readonly typesafeBackend: JudgmentBackend; readonly backendRefusal?: undefined }
| { readonly typesafeBackend?: undefined; readonly backendRefusal: string };

/** Why no judge is available: consent not given, the backend refused, no key for the backend, a saved 401 or 403, or the request budget spent. */
export type JudgmentsOffReason = "no_consent" | "bad_backend" | "no_key" | "key_rejected" | "budget";

/** The host the consent disclosure names as the destination, without scheme: `api.typesafe.ai`, `openrouter.ai`. */
export function backendHost(backend: JudgmentBackend): string {
return new URL(DECISIONS_BACKENDS[backend].host).host;
/**
* Resolve a raw config value to the spec judgments go to, kept as written, or to the refusal that turns judgments off.
* pi-typesafe validates the spec again on every call. An unknown name or an object it refuses is never silently mapped
* to the default backend: a mistyped value must not send judgments to api.typesafe.ai.
*/
export function resolveJudgmentBackend(raw: unknown): BackendResolution {
if (raw === undefined || raw === null) return { typesafeBackend: DEFAULT_BACKEND };
try {
resolveBackend(raw as BackendSpec);
} catch (error) {
return { backendRefusal: error instanceof Error ? error.message : String(error) };
}
return { typesafeBackend: raw as JudgmentBackend };
}

/** The environment variable that carries the backend's key, for messages that tell the user what to set. */
export function keyEnvFor(backend: JudgmentBackend): string {
return DECISIONS_BACKENDS[backend].keyEnv ?? DECISIONS_BACKENDS[DEFAULT_BACKEND].keyEnv ?? "TYPESAFE_API_KEY";
/** The name messages show for a backend: the registry name, or an endpoint's validated label. */
export function backendName(backend: JudgmentBackend | undefined): string {
return backend === undefined ? DEFAULT_BACKEND : typeof backend === "string" ? backend : resolveBackend(backend).label;
}

/** Whether the backend takes the TypeSafe key, the only one `/typesafe login` stores; pi-typesafe's `usesTypesafeKey`, which its package does not export. */
export function loginStoresKey(backend: JudgmentBackend): boolean {
return keyEnvFor(backend) === keyEnvFor(DEFAULT_BACKEND);
/** Whether the backend takes the TypeSafe key, the only one `/typesafe login` stores; true only for "typesafe". */
export function loginStoresKey(backend: JudgmentBackend | undefined): boolean {
return backend === DEFAULT_BACKEND;
}

/** One phrase naming where judgments go — the label, the host, and the model sent — for consent text and status lines. */
export function describeBackend(backend: JudgmentBackend | undefined): string {
const resolved = resolveBackend(backend);
return `${resolved.label} at ${backendHost(backend)}, model ${resolved.defaultModel ?? "none configured"}`;
}

/** Adapt the consent text to the active backend by substituting the destination host. */
export function disclosureFor(backend: JudgmentBackend, disclosure: string): string {
return backend === DEFAULT_BACKEND ? disclosure : disclosure.replace(backendHost(DEFAULT_BACKEND), backendHost(backend));
export function disclosureFor(backend: JudgmentBackend | undefined, disclosure: string): string {
return backend === undefined || backend === DEFAULT_BACKEND ? disclosure : disclosure.replace(backendHost(DEFAULT_BACKEND), backendHost(backend));
}

/**
Expand All @@ -38,8 +67,3 @@ export function judgeOptions(config: { maxRequests: number; timeoutMs: number; t
...(config.typesafeBackend === DEFAULT_BACKEND ? {} : { backend: config.typesafeBackend }),
};
}

/** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
export function resolveBackend(raw: unknown): JudgmentBackend {
return raw === "typesafe" || raw === "openrouter" ? raw : DEFAULT_BACKEND;
}
13 changes: 9 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { writeFileAtomicSync } from "./atomic.js";
import type { JudgmentBackend } from "./backend.js";
import { resolveBackend } from "./backend.js";
import { resolveJudgmentBackend } from "./backend.js";
import { defaultHostDirs } from "./host-dirs.js";
import type { HostDirs } from "./host-dirs.js";
import { COMMAND_TOOLS } from "./tools.js";
Expand Down Expand Up @@ -420,8 +420,10 @@ export interface WardenConfig {
enabled: boolean;
/** Consent to send task and action summaries to api.typesafe.ai. Set by /warden enable; never by a project file. */
typesafe: boolean;
/** The decisions service the judgments go to. User file only: a project must not redirect judgments to another vendor. */
typesafeBackend: JudgmentBackend;
/** The decisions service the judgments go to: a name ("typesafe", "openrouter", "commandcode") or a caller-supplied endpoint object. User file only: a project must not redirect judgments to another vendor. Undefined when the configured value was refused. */
typesafeBackend: JudgmentBackend | undefined;
/** Why the configured typesafeBackend was refused: judgments stay off, and the once-per-session notice and /warden status quote this. */
backendRefusal: string | undefined;
/**
* steer (default): a confirm-level call is held and the agent receives the judgment as its tool result, so it re-plans or asks
* the user in chat. confirm: open a dialog and let the user decide (falls back to steer without a UI). advise: never hold; report only.
Expand Down Expand Up @@ -476,6 +478,7 @@ export function defaultConfig(): WardenConfig {
enabled: true,
typesafe: false,
typesafeBackend: "typesafe",
backendRefusal: undefined,
mode: "steer",
timeoutMs: 5000,
maxRequests: 500,
Expand Down Expand Up @@ -930,10 +933,12 @@ function applyGuards(base: WardenConfig, raw: Json, timeoutMs: number, source: "
export function applyUserOverrides(base: WardenConfig, raw: unknown): WardenConfig {
if (!isObject(raw)) return base;
const shared = applyShared(base, raw);
const backend = resolveJudgmentBackend(raw.typesafeBackend);
return {
enabled: boolean(raw.enabled, base.enabled),
typesafe: boolean(raw.typesafe, base.typesafe),
typesafeBackend: resolveBackend(raw.typesafeBackend),
typesafeBackend: backend.typesafeBackend,
backendRefusal: backend.backendRefusal,
mode: isMode(raw.mode) ? raw.mode : base.mode,
...shared,
...applyGuards(base, raw, shared.timeoutMs, "user"),
Expand Down
1 change: 1 addition & 0 deletions src/conscience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface AssessmentResult {
export type SkipReason =
| "disabled"
| "no_consent"
| "bad_backend"
| "no_key"
| "key_rejected"
| "no_match"
Expand Down
Loading
Loading