pi-warden 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 +21 -0
- package/README.md +106 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +114 -0
- package/dist/extension.d.ts +4 -0
- package/dist/extension.js +205 -0
- package/dist/guard.d.ts +94 -0
- package/dist/guard.js +303 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +22 -0
- package/extensions/index.js +2 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Gapac
|
|
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
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# pi-warden
|
|
2
|
+
|
|
3
|
+
Harness guardrails for [Pi](https://pi.dev), built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe). Before the agent runs a `bash`, `write`, or `edit` call, pi-warden checks it twice: a fast offline pattern pass for the obvious hazards, then one TypeSafe (Jev) request that scores how **irreversible** and how **off-task** the action is relative to your latest request. High scores open a confirm dialog; you decide, and a declined call is blocked with a short reason the agent can act on.
|
|
4
|
+
|
|
5
|
+
Independent project. Not affiliated with TypeSafe AI or the Pi authors.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:pi-warden
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Pi 0.85 or newer and Node.js 22.19 or newer. Pattern checks work with no account. Jev judgments need a TypeSafe API key and are billed to your TypeSafe account (a judgment is roughly 600 input tokens; output is free).
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
1. Get a key at [console.typesafe.ai](https://console.typesafe.ai) (API Keys).
|
|
20
|
+
2. Run `/warden enable`. Read the data notice and confirm; if no key is configured yet, paste it at the hidden prompt. The key is verified against the API and saved to `~/.pi/agent/pi-typesafe/auth.json` with owner-only permissions, shared with [pi-typesafe](https://github.com/DevMortimer/pi-typesafe) and anything else built on it. Consent is saved to `~/.pi/agent/pi-warden/config.json`, so judgments stay on in every new session until you run `/warden disable`.
|
|
21
|
+
3. Optionally run `/warden test` to see one synthetic verdict.
|
|
22
|
+
|
|
23
|
+
A `TYPESAFE_API_KEY` environment variable takes precedence over the stored key. Without step 2, pi-warden still guards with offline pattern checks only.
|
|
24
|
+
|
|
25
|
+
## What it does on each guarded call
|
|
26
|
+
|
|
27
|
+
1. **Skip** read-only tools, and read-only shell lines such as `git status && ls` (no network, no widget).
|
|
28
|
+
2. **Pattern pass** (offline): force pushes, `git reset --hard`, `git clean -f`, recursive `rm` on absolute/home/variable/parent paths, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, block-device writes, `chmod -R 777`, fork bombs, `curl | sh`, `kill -1`, shutdown, package publishing, infrastructure destroys → **confirm**. `rm -rf` on a project path, `git checkout -- .`, `git branch -D`, `git stash drop`, `find -delete`, `sudo` → **warn**. Reads or writes of `.env`, SSH, AWS, npm, kube, and other credential files → **warn**. A `write` that overwrites an existing file outside the project → **confirm**; creating or editing outside the project → **warn**.
|
|
29
|
+
3. **Jev judgment** (with consent): one request with the state `{ task, action }` and three questions — `irreversible` (Noul), `off_task` (Noul), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 confirms; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` confirms. Pattern results set the floor; Jev can only raise it.
|
|
30
|
+
4. **Act**: allow silently, warn with a notification, or ask with `ctx.ui.confirm`. The widget above the editor shows the last verdict, for example `warden · bash · irreversible 0.84 · off-task 0.86 · unrelated · confirm`.
|
|
31
|
+
|
|
32
|
+
Headless runs (`pi -p`, JSON mode) cannot ask, so a confirm-level call is **blocked** unless `PI_WARDEN_HEADLESS=allow` or `"headless": "allow"` is set. Consent in headless runs comes from `PI_WARDEN_ENABLED=1`.
|
|
33
|
+
|
|
34
|
+
If TypeSafe cannot answer (timeout after 5 s, outage, budget), the call is allowed with a warning (`failOpen: true`). Set `failOpen` to `false` to ask instead. When the per-session request budget is spent, pi-warden says so once and continues with pattern checks.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
| Command | Effect |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `/warden status` | Guard state, consent source, key source, session counts, thresholds, config paths, last verdict |
|
|
41
|
+
| `/warden enable` | Show the data notice, prompt for a key if none is stored, and save consent for Jev judgments |
|
|
42
|
+
| `/warden disable` | Stop Jev judgments; pattern checks continue |
|
|
43
|
+
| `/warden config` | Edit the user config JSON in Pi's editor and save it |
|
|
44
|
+
| `/warden test` | Evaluate one synthetic destructive action and show the verdict |
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use these defaults:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"enabled": true,
|
|
53
|
+
"typesafe": false,
|
|
54
|
+
"headless": "block",
|
|
55
|
+
"action": {
|
|
56
|
+
"enabled": true,
|
|
57
|
+
"tools": ["bash", "write", "edit"],
|
|
58
|
+
"failOpen": true,
|
|
59
|
+
"timeoutMs": 5000,
|
|
60
|
+
"maxRequests": 500,
|
|
61
|
+
"irreversible": { "warn": 0.5, "confirm": 0.7 },
|
|
62
|
+
"offTask": { "warn": 0.6, "confirm": 0.85 }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A project may add `.pi/pi-warden.json` with `enabled` and `action` overrides (for example stricter thresholds or extra guarded tools). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent or change `headless`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_HEADLESS=allow|block`.
|
|
68
|
+
|
|
69
|
+
## Data handling
|
|
70
|
+
|
|
71
|
+
- With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 600-character content excerpt for `write`, and the first three edit pairs (200 characters each) for `edit`. No other files, history, or telemetry.
|
|
72
|
+
- Obvious credentials in the action (`Authorization` headers, `TOKEN=`/`SECRET=` assignments, `sk-`, `ghp_`, `AKIA`, JWTs, URL passwords, PEM blocks) are replaced with `[redacted]` before sending. This is best-effort; do not rely on it for prompts that contain secrets.
|
|
73
|
+
- The block reason returned to the agent names the tool and the reasons, not the command text. UI errors never include upstream response bodies or keys.
|
|
74
|
+
- Judgments are model output. Thresholds are yours to tune; the dialog is the decision.
|
|
75
|
+
|
|
76
|
+
## For extension authors
|
|
77
|
+
|
|
78
|
+
The guard is a plain function you can call from your own extension or tests, with any object that has pi-typesafe's `evaluate` method as the judge:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { evaluateAction, defaultConfig } from "pi-warden";
|
|
82
|
+
import { createTypeSafe } from "pi-typesafe";
|
|
83
|
+
|
|
84
|
+
const verdict = await evaluateAction(
|
|
85
|
+
{ tool: "bash", input: { command: "git push --force" }, cwd: process.cwd(), task: "push my branch" },
|
|
86
|
+
{ config: defaultConfig().action, judge: createTypeSafe() }, // omit judge for pattern checks only
|
|
87
|
+
);
|
|
88
|
+
verdict.level; // "allow" | "warn" | "confirm"
|
|
89
|
+
verdict.reasons; // ["destructive: git force push", "irreversible 0.91"]
|
|
90
|
+
verdict.judgment; // { irreversible, offTask, scope, scopeConfidence, model, elapsedMs }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, `questions`, and the config helpers.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm install
|
|
99
|
+
npm run check # typecheck, offline tests (mocked transport), build
|
|
100
|
+
npm run test:live # nine billable synthetic judgments against api.typesafe.ai (key from .env or the stored login)
|
|
101
|
+
npm run dev:pi # start Pi with this working tree plus an installed pi-typesafe
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface Threshold {
|
|
2
|
+
/** P(yes) at or above this shows a warning and continues. */
|
|
3
|
+
warn: number;
|
|
4
|
+
/** P(yes) at or above this asks the user before the tool runs. */
|
|
5
|
+
confirm: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ActionGuardConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
/** Tool names inspected before execution. Read-only tools are skipped to keep latency low. */
|
|
10
|
+
tools: string[];
|
|
11
|
+
/** When TypeSafe cannot answer (timeout, outage, budget), allow the call with a warning instead of asking. */
|
|
12
|
+
failOpen: boolean;
|
|
13
|
+
/** Per-request TypeSafe timeout. The call is judged as an error after this. */
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
/** Maximum TypeSafe requests per session for this guard. */
|
|
16
|
+
maxRequests: number;
|
|
17
|
+
irreversible: Threshold;
|
|
18
|
+
offTask: Threshold;
|
|
19
|
+
}
|
|
20
|
+
export interface WardenConfig {
|
|
21
|
+
/** Master switch. false disables every guard, including offline pattern checks. */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
/** Consent to send task and action summaries to api.typesafe.ai. Set by /warden enable; never by a project file. */
|
|
24
|
+
typesafe: boolean;
|
|
25
|
+
/** Decision when confirmation is required and no UI can ask. PI_WARDEN_HEADLESS overrides it. */
|
|
26
|
+
headless: "block" | "allow";
|
|
27
|
+
action: ActionGuardConfig;
|
|
28
|
+
}
|
|
29
|
+
export declare const PACKAGE_NAME = "pi-warden";
|
|
30
|
+
export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
|
|
31
|
+
export declare function defaultConfig(): WardenConfig;
|
|
32
|
+
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|
|
33
|
+
export declare function userConfigPath(): string;
|
|
34
|
+
export declare function projectConfigPath(cwd: string): string;
|
|
35
|
+
type Json = Record<string, unknown>;
|
|
36
|
+
/** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
|
|
37
|
+
export declare function applyUserOverrides(base: WardenConfig, raw: unknown): WardenConfig;
|
|
38
|
+
/** Project files may tune the guard but cannot grant TypeSafe consent or change headless policy. */
|
|
39
|
+
export declare function applyProjectOverrides(base: WardenConfig, raw: unknown): WardenConfig;
|
|
40
|
+
export interface LoadOptions {
|
|
41
|
+
cwd?: string;
|
|
42
|
+
/** Project overrides are applied only when the caller vouches for the project (Pi's trust decision). */
|
|
43
|
+
projectTrusted?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export declare function loadConfig(options?: LoadOptions): WardenConfig;
|
|
46
|
+
/** Reads only the user file, for editing and persisting consent. */
|
|
47
|
+
export declare function readUserConfig(): Json;
|
|
48
|
+
export declare function writeUserConfig(raw: Json): string;
|
|
49
|
+
/** Persists one top-level user setting without disturbing the rest of the file. */
|
|
50
|
+
export declare function setUserSetting(key: "typesafe" | "enabled" | "headless", value: boolean | "block" | "allow"): string;
|
|
51
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export const PACKAGE_NAME = "pi-warden";
|
|
5
|
+
export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
|
|
6
|
+
export function defaultConfig() {
|
|
7
|
+
return {
|
|
8
|
+
enabled: true,
|
|
9
|
+
typesafe: false,
|
|
10
|
+
headless: "block",
|
|
11
|
+
action: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
tools: ["bash", "write", "edit"],
|
|
14
|
+
failOpen: true,
|
|
15
|
+
timeoutMs: 5000,
|
|
16
|
+
maxRequests: 500,
|
|
17
|
+
irreversible: { warn: 0.5, confirm: 0.7 },
|
|
18
|
+
offTask: { warn: 0.6, confirm: 0.85 },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|
|
23
|
+
export function userConfigPath() {
|
|
24
|
+
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
25
|
+
const agentDir = configured
|
|
26
|
+
? (configured === "~" || configured.startsWith("~/") ? join(homedir(), configured.slice(1)) : configured)
|
|
27
|
+
: join(homedir(), ".pi", "agent");
|
|
28
|
+
return join(agentDir, PACKAGE_NAME, "config.json");
|
|
29
|
+
}
|
|
30
|
+
export function projectConfigPath(cwd) {
|
|
31
|
+
return join(cwd, ".pi", PROJECT_CONFIG_FILE);
|
|
32
|
+
}
|
|
33
|
+
function isObject(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function readJson(path) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
39
|
+
return isObject(parsed) ? parsed : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function probability(value, fallback) {
|
|
46
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
function threshold(value, fallback) {
|
|
49
|
+
if (!isObject(value))
|
|
50
|
+
return fallback;
|
|
51
|
+
const warn = probability(value.warn, fallback.warn);
|
|
52
|
+
const confirm = probability(value.confirm, fallback.confirm);
|
|
53
|
+
return { warn: Math.min(warn, confirm), confirm };
|
|
54
|
+
}
|
|
55
|
+
function boolean(value, fallback) {
|
|
56
|
+
return typeof value === "boolean" ? value : fallback;
|
|
57
|
+
}
|
|
58
|
+
function positiveInteger(value, fallback) {
|
|
59
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
60
|
+
}
|
|
61
|
+
function applyAction(base, raw) {
|
|
62
|
+
if (!isObject(raw))
|
|
63
|
+
return base;
|
|
64
|
+
const tools = Array.isArray(raw.tools) ? raw.tools.filter((tool) => typeof tool === "string" && tool.trim().length > 0) : base.tools;
|
|
65
|
+
return {
|
|
66
|
+
enabled: boolean(raw.enabled, base.enabled),
|
|
67
|
+
tools,
|
|
68
|
+
failOpen: boolean(raw.failOpen, base.failOpen),
|
|
69
|
+
timeoutMs: positiveInteger(raw.timeoutMs, base.timeoutMs),
|
|
70
|
+
maxRequests: positiveInteger(raw.maxRequests, base.maxRequests),
|
|
71
|
+
irreversible: threshold(raw.irreversible, base.irreversible),
|
|
72
|
+
offTask: threshold(raw.offTask, base.offTask),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
|
|
76
|
+
export function applyUserOverrides(base, raw) {
|
|
77
|
+
if (!isObject(raw))
|
|
78
|
+
return base;
|
|
79
|
+
return {
|
|
80
|
+
enabled: boolean(raw.enabled, base.enabled),
|
|
81
|
+
typesafe: boolean(raw.typesafe, base.typesafe),
|
|
82
|
+
headless: raw.headless === "allow" || raw.headless === "block" ? raw.headless : base.headless,
|
|
83
|
+
action: applyAction(base.action, raw.action),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Project files may tune the guard but cannot grant TypeSafe consent or change headless policy. */
|
|
87
|
+
export function applyProjectOverrides(base, raw) {
|
|
88
|
+
if (!isObject(raw))
|
|
89
|
+
return base;
|
|
90
|
+
return { ...base, enabled: boolean(raw.enabled, base.enabled), action: applyAction(base.action, raw.action) };
|
|
91
|
+
}
|
|
92
|
+
export function loadConfig(options = {}) {
|
|
93
|
+
let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
|
|
94
|
+
if (options.cwd && options.projectTrusted)
|
|
95
|
+
config = applyProjectOverrides(config, readJson(projectConfigPath(options.cwd)));
|
|
96
|
+
return config;
|
|
97
|
+
}
|
|
98
|
+
/** Reads only the user file, for editing and persisting consent. */
|
|
99
|
+
export function readUserConfig() {
|
|
100
|
+
return readJson(userConfigPath()) ?? {};
|
|
101
|
+
}
|
|
102
|
+
export function writeUserConfig(raw) {
|
|
103
|
+
const path = userConfigPath();
|
|
104
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
105
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
106
|
+
writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600, flag: "w" });
|
|
107
|
+
chmodSync(temporary, 0o600);
|
|
108
|
+
renameSync(temporary, path);
|
|
109
|
+
return path;
|
|
110
|
+
}
|
|
111
|
+
/** Persists one top-level user setting without disturbing the rest of the file. */
|
|
112
|
+
export function setUserSetting(key, value) {
|
|
113
|
+
return writeUserConfig({ ...readUserConfig(), [key]: value });
|
|
114
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends the latest user request plus a redacted, truncated summary of each guarded bash, write, or edit call (command text, file path, content excerpt) to api.typesafe.ai before the tool runs. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
3
|
+
/** Native Pi registration; importing the root library does not load this module. */
|
|
4
|
+
export default function wardenExtension(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createTypeSafe, resolveApiKey } from "pi-typesafe";
|
|
2
|
+
import { ensureApiKey } from "pi-typesafe/ui";
|
|
3
|
+
import { applyUserOverrides, defaultConfig, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
4
|
+
import { evaluateAction, formatVerdict } from "./guard.js";
|
|
5
|
+
export const disclosure = "With TypeSafe judgments enabled, pi-warden sends the latest user request plus a redacted, truncated summary of each guarded bash, write, or edit call (command text, file path, content excerpt) to api.typesafe.ai before the tool runs. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
6
|
+
const WIDGET = PACKAGE_NAME;
|
|
7
|
+
const CONFIRM_TEXT_LIMIT = 500;
|
|
8
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, confirmed: 0, blocked: 0, errors: 0 });
|
|
9
|
+
function latestUserPrompt(ctx) {
|
|
10
|
+
const entries = ctx.sessionManager.getBranch();
|
|
11
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
12
|
+
const entry = entries[index];
|
|
13
|
+
if (entry?.type !== "message" || entry.message.role !== "user")
|
|
14
|
+
continue;
|
|
15
|
+
const content = entry.message.content;
|
|
16
|
+
if (typeof content === "string")
|
|
17
|
+
return content;
|
|
18
|
+
return content.filter((part) => part.type === "text").map(part => part.text).join("\n");
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function headlessPolicy(config) {
|
|
23
|
+
const env = process.env.PI_WARDEN_HEADLESS?.trim();
|
|
24
|
+
return env === "allow" || env === "block" ? env : config.headless;
|
|
25
|
+
}
|
|
26
|
+
function clip(text) {
|
|
27
|
+
return text.length <= CONFIRM_TEXT_LIMIT ? text : `${text.slice(0, CONFIRM_TEXT_LIMIT)}…`;
|
|
28
|
+
}
|
|
29
|
+
function confirmMessage(verdict) {
|
|
30
|
+
const { summary } = verdict;
|
|
31
|
+
const lines = [];
|
|
32
|
+
if (summary.command !== undefined)
|
|
33
|
+
lines.push(clip(summary.command));
|
|
34
|
+
if (summary.path !== undefined)
|
|
35
|
+
lines.push(`${summary.tool} ${summary.path}${summary.location === "outside_project" ? " (outside the project)" : ""}${summary.exists === false ? " (new file)" : ""}`);
|
|
36
|
+
if (summary.input !== undefined)
|
|
37
|
+
lines.push(clip(summary.input));
|
|
38
|
+
lines.push("", `Why: ${verdict.reasons.join("; ")}`);
|
|
39
|
+
if (verdict.judgment)
|
|
40
|
+
lines.push(`Jev: irreversible ${verdict.judgment.irreversible.toFixed(2)}, off-task ${verdict.judgment.offTask.toFixed(2)}, ${verdict.judgment.scope.replace(/_/g, " ")} (${verdict.judgment.model}, ${verdict.judgment.elapsedMs} ms)`);
|
|
41
|
+
lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
/** Native Pi registration; importing the root library does not load this module. */
|
|
45
|
+
export default function wardenExtension(pi) {
|
|
46
|
+
let client;
|
|
47
|
+
let budgetExhausted = false;
|
|
48
|
+
let stats = freshStats();
|
|
49
|
+
let lastVerdict;
|
|
50
|
+
const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
51
|
+
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
52
|
+
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
53
|
+
const judgeFor = (config) => {
|
|
54
|
+
if (!consentGiven(config) || budgetExhausted || !resolveApiKey())
|
|
55
|
+
return undefined;
|
|
56
|
+
return client ??= createTypeSafe({ maxRequests: config.action.maxRequests, timeoutMs: config.action.timeoutMs });
|
|
57
|
+
};
|
|
58
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
59
|
+
client = undefined;
|
|
60
|
+
budgetExhausted = false;
|
|
61
|
+
stats = freshStats();
|
|
62
|
+
lastVerdict = undefined;
|
|
63
|
+
if (ctx.hasUI)
|
|
64
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
65
|
+
});
|
|
66
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
67
|
+
const config = configFor(ctx);
|
|
68
|
+
if (!config.enabled || !config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
69
|
+
return;
|
|
70
|
+
stats.inspected++;
|
|
71
|
+
const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task: latestUserPrompt(ctx) }, { config: config.action, judge: judgeFor(config), signal: ctx.signal });
|
|
72
|
+
lastVerdict = verdict;
|
|
73
|
+
if (verdict.judgment)
|
|
74
|
+
stats.judged++;
|
|
75
|
+
if (verdict.source === "error") {
|
|
76
|
+
stats.errors++;
|
|
77
|
+
if (verdict.errorCode === "budget")
|
|
78
|
+
budgetExhausted = true;
|
|
79
|
+
if (ctx.hasUI)
|
|
80
|
+
ctx.ui.notify(`warden: ${verdict.error}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
|
|
81
|
+
}
|
|
82
|
+
if (ctx.hasUI && verdict.source !== "read-only")
|
|
83
|
+
ctx.ui.setWidget(WIDGET, [formatVerdict(verdict)]);
|
|
84
|
+
if (verdict.level === "warn") {
|
|
85
|
+
stats.warned++;
|
|
86
|
+
if (ctx.hasUI)
|
|
87
|
+
ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (verdict.level !== "confirm")
|
|
91
|
+
return undefined;
|
|
92
|
+
stats.confirmed++;
|
|
93
|
+
const reasons = verdict.reasons.join("; ");
|
|
94
|
+
if (ctx.hasUI) {
|
|
95
|
+
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
96
|
+
if (allowed)
|
|
97
|
+
return undefined;
|
|
98
|
+
stats.blocked++;
|
|
99
|
+
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
100
|
+
}
|
|
101
|
+
if (headlessPolicy(config) === "allow")
|
|
102
|
+
return undefined;
|
|
103
|
+
stats.blocked++;
|
|
104
|
+
return { block: true, reason: `pi-warden blocked this ${event.toolName} call because it needs user confirmation (${reasons}) and no user is present. Choose a safer approach or stop and report. Operators can set PI_WARDEN_HEADLESS=allow to permit such calls.` };
|
|
105
|
+
});
|
|
106
|
+
const actions = ["status", "enable", "disable", "config", "test"];
|
|
107
|
+
pi.registerCommand("warden", {
|
|
108
|
+
description: "pi-warden status, TypeSafe consent, config editor, and a synthetic guard test",
|
|
109
|
+
getArgumentCompletions(prefix) {
|
|
110
|
+
const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
111
|
+
return matches.length ? matches : null;
|
|
112
|
+
},
|
|
113
|
+
async handler(args, ctx) {
|
|
114
|
+
const action = args.trim() || "status";
|
|
115
|
+
const report = (text, level = "info") => {
|
|
116
|
+
if (ctx.hasUI)
|
|
117
|
+
ctx.ui.notify(text, level);
|
|
118
|
+
else
|
|
119
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
const config = configFor(ctx);
|
|
123
|
+
if (action === "status") {
|
|
124
|
+
const key = resolveApiKey();
|
|
125
|
+
const source = consentSource(config);
|
|
126
|
+
const usage = client?.getUsage();
|
|
127
|
+
report([
|
|
128
|
+
`pi-warden: ${config.enabled && config.action.enabled ? "guarding" : "off"} ${config.action.tools.join(", ")}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
|
|
129
|
+
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.confirmed} asked, ${stats.blocked} blocked, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.action.maxRequests} requests.`,
|
|
130
|
+
`Thresholds: irreversible warn ${config.action.irreversible.warn} / confirm ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / confirm ${config.action.offTask.confirm}; failOpen ${config.action.failOpen}; headless ${headlessPolicy(config)}.`,
|
|
131
|
+
`Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
|
|
132
|
+
lastVerdict ? `Last: ${formatVerdict(lastVerdict)}` : "No guarded tool calls yet this session.",
|
|
133
|
+
].join(" "));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (action === "enable") {
|
|
137
|
+
if (!ctx.hasUI) {
|
|
138
|
+
report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", disclosure))
|
|
142
|
+
return;
|
|
143
|
+
// One flow: consent, then a key if none is configured yet (hidden input, verified, stored for every pi-typesafe consumer).
|
|
144
|
+
const key = await ensureApiKey(ctx);
|
|
145
|
+
if (!key) {
|
|
146
|
+
report("No key entered; pi-warden stays on pattern checks only. Run /warden enable again when you have a key from console.typesafe.ai.", "warning");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const path = setUserSetting("typesafe", true);
|
|
150
|
+
client = undefined;
|
|
151
|
+
budgetExhausted = false;
|
|
152
|
+
report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : "key from TYPESAFE_API_KEY"}`}. This stays on in new sessions until /warden disable.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (action === "disable") {
|
|
156
|
+
const path = setUserSetting("typesafe", false);
|
|
157
|
+
report(`TypeSafe judgments disabled in ${path}. Offline pattern checks stay active; set enabled to false there to turn pi-warden off entirely.`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (action === "config") {
|
|
161
|
+
if (!ctx.hasUI) {
|
|
162
|
+
report(`Edit ${userConfigPath()} directly. Defaults: ${JSON.stringify(defaultConfig())}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const current = readUserConfig();
|
|
166
|
+
const seed = Object.keys(current).length ? current : { ...defaultConfig(), typesafe: config.typesafe };
|
|
167
|
+
const text = await ctx.ui.editor(`pi-warden config · ${userConfigPath()}`, JSON.stringify(seed, null, 2));
|
|
168
|
+
if (text === undefined)
|
|
169
|
+
return;
|
|
170
|
+
let parsed;
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(text);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
report("Invalid JSON; nothing was saved.", "error");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
179
|
+
report("The config must be a JSON object; nothing was saved.", "error");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const path = writeUserConfig(parsed);
|
|
183
|
+
const effective = applyUserOverrides(defaultConfig(), parsed);
|
|
184
|
+
client = undefined;
|
|
185
|
+
report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible confirm ≥ ${effective.action.irreversible.confirm}, off-task confirm ≥ ${effective.action.offTask.confirm}.`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (action === "test") {
|
|
189
|
+
const judge = judgeFor(config);
|
|
190
|
+
if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to api.typesafe.ai and may incur charges. ${disclosure}`))
|
|
191
|
+
return;
|
|
192
|
+
const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
|
|
193
|
+
if (ctx.hasUI)
|
|
194
|
+
ctx.ui.setWidget(WIDGET, [formatVerdict(verdict)]);
|
|
195
|
+
report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
report(`Unknown action "${action}". Use: ${actions.join(", ")}.`, "warning");
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
report(error instanceof Error ? error.message : "pi-warden command failed.", "error");
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { IntegrationErrorCode, TypeSafe } from "pi-typesafe";
|
|
2
|
+
import type { ActionGuardConfig } from "./config.js";
|
|
3
|
+
export type Level = "allow" | "warn" | "confirm";
|
|
4
|
+
export type Severity = "destructive" | "risky" | "sensitive";
|
|
5
|
+
export interface PatternHit {
|
|
6
|
+
id: string;
|
|
7
|
+
severity: Severity;
|
|
8
|
+
/** Short human label; never contains the matched text. */
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ActionInput {
|
|
12
|
+
tool: string;
|
|
13
|
+
input: Record<string, unknown>;
|
|
14
|
+
cwd: string;
|
|
15
|
+
/** Latest user request, used to judge whether the action is on task. */
|
|
16
|
+
task?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Redacted, truncated view of a tool call. This object is what leaves the machine. */
|
|
19
|
+
export interface ActionSummary {
|
|
20
|
+
tool: string;
|
|
21
|
+
command?: string;
|
|
22
|
+
path?: string;
|
|
23
|
+
location?: "inside_project" | "outside_project";
|
|
24
|
+
exists?: boolean;
|
|
25
|
+
bytes?: number;
|
|
26
|
+
excerpt?: string;
|
|
27
|
+
editCount?: number;
|
|
28
|
+
edits?: Array<{
|
|
29
|
+
oldText: string;
|
|
30
|
+
newText: string;
|
|
31
|
+
}>;
|
|
32
|
+
input?: string;
|
|
33
|
+
}
|
|
34
|
+
export type ScopeLabel = "expected_step" | "plausible_side_step" | "unrelated" | "unclear";
|
|
35
|
+
export interface Judgment {
|
|
36
|
+
irreversible: number;
|
|
37
|
+
offTask: number;
|
|
38
|
+
scope: ScopeLabel;
|
|
39
|
+
scopeConfidence: number;
|
|
40
|
+
model: string;
|
|
41
|
+
elapsedMs: number;
|
|
42
|
+
}
|
|
43
|
+
export interface Verdict {
|
|
44
|
+
level: Level;
|
|
45
|
+
source: "skipped" | "read-only" | "pattern" | "typesafe" | "error";
|
|
46
|
+
summary: ActionSummary;
|
|
47
|
+
patterns: PatternHit[];
|
|
48
|
+
/** Human-readable reasons without secrets or full commands. */
|
|
49
|
+
reasons: string[];
|
|
50
|
+
judgment?: Judgment;
|
|
51
|
+
/** Safe TypeSafe error message when the judge could not answer. */
|
|
52
|
+
error?: string;
|
|
53
|
+
errorCode?: IntegrationErrorCode;
|
|
54
|
+
}
|
|
55
|
+
/** The subset of pi-typesafe's client the guard needs; tests supply a fake. */
|
|
56
|
+
export type Judge = Pick<TypeSafe, "evaluate">;
|
|
57
|
+
export interface EvaluateOptions {
|
|
58
|
+
config: ActionGuardConfig;
|
|
59
|
+
/** Omit to run offline pattern checks only (no consent, no network). */
|
|
60
|
+
judge?: Judge | undefined;
|
|
61
|
+
signal?: AbortSignal | undefined;
|
|
62
|
+
}
|
|
63
|
+
export declare function matchPatterns(tool: string, input: Record<string, unknown>, cwd?: string): PatternHit[];
|
|
64
|
+
export declare function isReadOnlyCommand(command: string): boolean;
|
|
65
|
+
export declare function describeAction(tool: string, input: Record<string, unknown>, cwd: string): ActionSummary;
|
|
66
|
+
export declare const questions: {
|
|
67
|
+
irreversible: import("pi-typesafe").NoulQuestion;
|
|
68
|
+
off_task: import("pi-typesafe").NoulQuestion;
|
|
69
|
+
scope: import("pi-typesafe").ChoiceQuestion<{
|
|
70
|
+
readonly expected_step: "A step that `task` directly requires";
|
|
71
|
+
readonly plausible_side_step: "Reasonable preparation, inspection, verification, or cleanup while doing `task`";
|
|
72
|
+
readonly unrelated: "Not connected to `task`";
|
|
73
|
+
readonly unclear: "`task` or `action` gives too little information to tell";
|
|
74
|
+
}>;
|
|
75
|
+
};
|
|
76
|
+
export declare function buildRequest(summary: ActionSummary, task: string | undefined): {
|
|
77
|
+
state: {
|
|
78
|
+
task: string;
|
|
79
|
+
action: Record<string, string | number | boolean>;
|
|
80
|
+
};
|
|
81
|
+
questions: {
|
|
82
|
+
irreversible: import("pi-typesafe").NoulQuestion;
|
|
83
|
+
off_task: import("pi-typesafe").NoulQuestion;
|
|
84
|
+
scope: import("pi-typesafe").ChoiceQuestion<{
|
|
85
|
+
readonly expected_step: "A step that `task` directly requires";
|
|
86
|
+
readonly plausible_side_step: "Reasonable preparation, inspection, verification, or cleanup while doing `task`";
|
|
87
|
+
readonly unrelated: "Not connected to `task`";
|
|
88
|
+
readonly unclear: "`task` or `action` gives too little information to tell";
|
|
89
|
+
}>;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
export declare function evaluateAction(action: ActionInput, options: EvaluateOptions): Promise<Verdict>;
|
|
93
|
+
/** One-line rendering for widgets and logs. Includes no command text. */
|
|
94
|
+
export declare function formatVerdict(verdict: Verdict): string;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { choice, noul, TypeSafeIntegrationError } from "pi-typesafe";
|
|
5
|
+
import { redact } from "./redact.js";
|
|
6
|
+
const LEVEL_RANK = { allow: 0, warn: 1, confirm: 2 };
|
|
7
|
+
const higher = (a, b) => (LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b);
|
|
8
|
+
const TASK_LIMIT = 1500;
|
|
9
|
+
const COMMAND_LIMIT = 2000;
|
|
10
|
+
const EXCERPT_LIMIT = 600;
|
|
11
|
+
const EDIT_LIMIT = 200;
|
|
12
|
+
function truncate(text, limit) {
|
|
13
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}… [${text.length - limit} more chars]`;
|
|
14
|
+
}
|
|
15
|
+
const SHELL_RULES = [
|
|
16
|
+
{ id: "git-force-push", severity: "destructive", label: "git force push", test: /\bgit\s+push\b[^\n;&|]*\s(?:-f|--force)(?![-\w])/ },
|
|
17
|
+
{ id: "git-force-with-lease", severity: "risky", label: "git push --force-with-lease", test: /\bgit\s+push\b[^\n;&|]*--force-with-lease/ },
|
|
18
|
+
{ id: "git-reset-hard", severity: "destructive", label: "git reset --hard", test: /\bgit\s+reset\b[^\n;&|]*--hard/ },
|
|
19
|
+
{ id: "git-clean", severity: "destructive", label: "git clean (removes untracked files)", test: /\bgit\s+clean\b[^\n;&|]*\s-[a-zA-Z]*[fFxX]/ },
|
|
20
|
+
{ id: "git-checkout-discard", severity: "risky", label: "git checkout/restore discards working changes", test: /\bgit\s+checkout\s+(?:--\s+\S|(?:\.|\*)(?=\s|$))|\bgit\s+restore\b(?:(?![^\n;&|]*--staged)|(?=[^\n;&|]*(?:--worktree|\s-\w*W)))/ },
|
|
21
|
+
{ id: "git-branch-force-delete", severity: "risky", label: "git branch -D", test: /\bgit\s+branch\b[^\n;&|]*\s-D\b/ },
|
|
22
|
+
{ id: "git-stash-drop", severity: "risky", label: "git stash drop/clear", test: /\bgit\s+stash\s+(?:drop|clear)\b/ },
|
|
23
|
+
{ id: "sql-drop", severity: "destructive", label: "SQL DROP", test: /\bdrop\s+(?:table|database|schema|index|view|user|role)\b/i },
|
|
24
|
+
{ id: "sql-truncate", severity: "destructive", label: "SQL TRUNCATE", test: /\btruncate\s+(?:table\s+)?\w/i },
|
|
25
|
+
{ id: "sql-delete", severity: "destructive", label: "SQL DELETE FROM", test: /\bdelete\s+from\s+\w/i },
|
|
26
|
+
{ id: "block-device-write", severity: "destructive", label: "write to a block device", test: /(?:\bdd\b[^\n;&|]*\bof=\/dev\/|>\s*\/dev\/(?:sd|hd|nvme|disk|mmcblk|vd)|\bmkfs(?:\.\w+)?\b|\bwipefs\b|\bfdisk\b|\bparted\b)/ },
|
|
27
|
+
{ id: "chmod-777", severity: "destructive", label: "chmod -R 777", test: /\bchmod\b[^\n;&|]*\s-[a-zA-Z]*R[a-zA-Z]*\s+[0-7]*777\b|\bchmod\b[^\n;&|]*\s777\s+[^\n;&|]*\s-[a-zA-Z]*R/ },
|
|
28
|
+
{ id: "fork-bomb", severity: "destructive", label: "fork bomb", test: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/ },
|
|
29
|
+
{ id: "remote-script-exec", severity: "destructive", label: "pipe remote script into a shell", test: /\b(?:curl|wget)\b[^\n;&]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b/ },
|
|
30
|
+
{ id: "kill-all", severity: "destructive", label: "kill every process", test: /\bkill\s+(?:-\w+\s+)*-1\b|\bkillall5\b/ },
|
|
31
|
+
{ id: "power", severity: "destructive", label: "shutdown/reboot", test: /(?:^|[;&|(]\s*|\bsudo\s+)(?:shutdown|reboot|halt|poweroff)\b/m },
|
|
32
|
+
{ id: "npm-publish", severity: "destructive", label: "publish a package", test: /\b(?:npm|pnpm|yarn)\s+publish\b|\bcargo\s+publish\b|\btwine\s+upload\b/ },
|
|
33
|
+
{ id: "infra-destroy", severity: "destructive", label: "destroy infrastructure", test: /\b(?:terraform|tofu|pulumi)\s+destroy\b|\bkubectl\s+delete\b|\bhelm\s+(?:uninstall|delete)\b|\bdocker\s+(?:system\s+prune|volume\s+rm|rm\s+-[a-z]*f)/ },
|
|
34
|
+
{ id: "find-delete", severity: "risky", label: "find -delete / -exec rm", test: /\bfind\b[^\n;&|]*(?:-delete\b|-exec\w*\s+rm\b)/ },
|
|
35
|
+
{ id: "sudo", severity: "risky", label: "sudo", test: /(?:^|[\s;&|(])sudo\s/ },
|
|
36
|
+
];
|
|
37
|
+
const SENSITIVE_PATH = /(?:^|[\s/"'=:(])\.env(?:\.(?!example\b|sample\b|template\b|dist\b)[\w.-]+)?(?=$|[\s"';|&)])|(?:^|[\s"'=:/~])\.?(?:ssh\/(?:id_\w+|authorized_keys|known_hosts)|aws\/credentials|gnupg\/|netrc\b|npmrc\b|pypirc\b|docker\/config\.json|kube\/config\b|pi\/agent\/auth\.json|pi\/agent\/pi-typesafe\/auth\.json)|\b\w+\.(?:pem|p12|pfx|keystore|jks)\b|\bid_(?:rsa|ed25519|ecdsa|dsa)\b/i;
|
|
38
|
+
function splitShell(command) {
|
|
39
|
+
return command.split(/\n|;|&&|\|\||\||&/).map(part => part.trim()).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
/** rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are risky. */
|
|
42
|
+
function classifyRm(segment, cwd) {
|
|
43
|
+
const match = /(?:^|\s)rm\s+(.*)$/.exec(segment);
|
|
44
|
+
if (!match)
|
|
45
|
+
return undefined;
|
|
46
|
+
const tokens = match[1].split(/\s+/).filter(Boolean);
|
|
47
|
+
const flags = tokens.filter(token => token.startsWith("-"));
|
|
48
|
+
const targets = tokens.filter(token => !token.startsWith("-"));
|
|
49
|
+
const recursive = flags.some(flag => flag === "--recursive" || (/^-[a-zA-Z]+$/.test(flag) && /[rR]/.test(flag)));
|
|
50
|
+
const force = flags.some(flag => flag === "--force" || (/^-[a-zA-Z]+$/.test(flag) && flag.includes("f")));
|
|
51
|
+
if (!recursive)
|
|
52
|
+
return undefined;
|
|
53
|
+
const dangerousTarget = targets.some(target => {
|
|
54
|
+
const clean = target.replace(/^["']|["']$/g, "");
|
|
55
|
+
if (clean === "/" || clean === "~" || clean === "*" || clean === "." || clean === ".." || clean.startsWith("~/") || clean.startsWith("$") || clean.startsWith("/*") || clean === "./" || clean === "../")
|
|
56
|
+
return true;
|
|
57
|
+
if (isAbsolute(clean))
|
|
58
|
+
return cwd ? !isInside(clean, cwd) : true;
|
|
59
|
+
return clean.split(/[\\/]/).includes("..");
|
|
60
|
+
});
|
|
61
|
+
if (dangerousTarget)
|
|
62
|
+
return { id: "rm-recursive-dangerous-target", severity: "destructive", label: "recursive rm on an absolute, home, variable, or parent path" };
|
|
63
|
+
if (force)
|
|
64
|
+
return { id: "rm-rf", severity: "risky", label: "rm -rf on a project path" };
|
|
65
|
+
return { id: "rm-recursive", severity: "risky", label: "recursive rm" };
|
|
66
|
+
}
|
|
67
|
+
function isInside(target, cwd) {
|
|
68
|
+
const rel = relative(resolve(cwd), resolve(target));
|
|
69
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
70
|
+
}
|
|
71
|
+
export function matchPatterns(tool, input, cwd) {
|
|
72
|
+
const hits = new Map();
|
|
73
|
+
const add = (hit) => { if (hit && !hits.has(hit.id))
|
|
74
|
+
hits.set(hit.id, hit); };
|
|
75
|
+
const command = typeof input.command === "string" ? input.command : undefined;
|
|
76
|
+
if (command) {
|
|
77
|
+
for (const rule of SHELL_RULES)
|
|
78
|
+
if (rule.test.test(command))
|
|
79
|
+
add({ id: rule.id, severity: rule.severity, label: rule.label });
|
|
80
|
+
for (const segment of splitShell(command))
|
|
81
|
+
add(classifyRm(segment, cwd));
|
|
82
|
+
if (SENSITIVE_PATH.test(command))
|
|
83
|
+
add({ id: "sensitive-path", severity: "sensitive", label: "touches a secrets or credentials file" });
|
|
84
|
+
}
|
|
85
|
+
const path = typeof input.path === "string" ? input.path : undefined;
|
|
86
|
+
if (path && SENSITIVE_PATH.test(path))
|
|
87
|
+
add({ id: "sensitive-path", severity: "sensitive", label: "touches a secrets or credentials file" });
|
|
88
|
+
return [...hits.values()];
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Read-only shell detection: a latency optimisation, not a security boundary. Runs only when no pattern matched.
|
|
92
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
93
|
+
"ls", "cat", "head", "tail", "less", "more", "wc", "grep", "rg", "egrep", "fgrep", "ag", "find", "fd", "pwd", "echo", "printf", "which", "whereis", "type",
|
|
94
|
+
"file", "stat", "du", "df", "tree", "diff", "sort", "uniq", "cut", "tr", "cd", "true", "false", "test", "[", "date", "basename", "dirname", "realpath",
|
|
95
|
+
"readlink", "jq", "column", "nl", "strings", "md5", "md5sum", "shasum", "sha1sum", "sha256sum", "hexdump", "xxd", "od", "uname", "hostname", "whoami", "id", "uptime",
|
|
96
|
+
]);
|
|
97
|
+
const READ_ONLY_GIT = new Set(["status", "log", "diff", "show", "blame", "ls-files", "ls-tree", "rev-parse", "describe", "shortlog", "grep", "cat-file", "rev-list", "name-rev"]);
|
|
98
|
+
export function isReadOnlyCommand(command) {
|
|
99
|
+
if (!command.trim() || /\$\(|`/.test(command))
|
|
100
|
+
return false;
|
|
101
|
+
const stripped = command.replace(/\d?>\s*&\s*\d/g, "").replace(/&?\d?>\s*\/dev\/null/g, "");
|
|
102
|
+
if (stripped.includes(">"))
|
|
103
|
+
return false;
|
|
104
|
+
for (const segment of splitShell(stripped)) {
|
|
105
|
+
const tokens = segment.split(/\s+/);
|
|
106
|
+
let index = 0;
|
|
107
|
+
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index]))
|
|
108
|
+
index++;
|
|
109
|
+
const head = tokens[index];
|
|
110
|
+
if (!head)
|
|
111
|
+
return false;
|
|
112
|
+
if (head === "git") {
|
|
113
|
+
const rest = tokens.slice(index + 1).join(" ");
|
|
114
|
+
const sub = tokens[index + 1];
|
|
115
|
+
if (!sub)
|
|
116
|
+
return false;
|
|
117
|
+
if (sub === "branch") {
|
|
118
|
+
if (/\s-[a-zA-Z]*[dDmMcCu]|--(?:delete|move|copy|set-upstream|unset-upstream|edit-description)/.test(` ${rest}`))
|
|
119
|
+
return false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (sub === "remote") {
|
|
123
|
+
if (tokens.slice(index + 2).some(token => !token.startsWith("-")))
|
|
124
|
+
return false;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (sub === "tag") {
|
|
128
|
+
if (!tokens.slice(index + 2).every(token => token === "-l" || token === "--list" || token.startsWith("-n")))
|
|
129
|
+
return false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (sub === "config") {
|
|
133
|
+
if (!/--get|--list|-l\b/.test(rest))
|
|
134
|
+
return false;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!READ_ONLY_GIT.has(sub))
|
|
138
|
+
return false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (head === "find" && /-(?:delete|exec\w*|ok\w*|fprint\w*|fls)\b/.test(segment))
|
|
142
|
+
return false;
|
|
143
|
+
if (!READ_ONLY_COMMANDS.has(head))
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Action summary: what is shown to the user and what is sent to TypeSafe.
|
|
150
|
+
function displayPath(target, cwd) {
|
|
151
|
+
const absolute = resolve(cwd, target);
|
|
152
|
+
if (isInside(absolute, cwd)) {
|
|
153
|
+
const rel = relative(resolve(cwd), absolute);
|
|
154
|
+
return { path: rel === "" ? "." : rel.split(sep).join("/"), location: "inside_project" };
|
|
155
|
+
}
|
|
156
|
+
const home = homedir();
|
|
157
|
+
const shown = absolute === home || absolute.startsWith(home + sep) ? `~${absolute.slice(home.length)}` : absolute;
|
|
158
|
+
return { path: shown, location: "outside_project" };
|
|
159
|
+
}
|
|
160
|
+
export function describeAction(tool, input, cwd) {
|
|
161
|
+
const summary = { tool };
|
|
162
|
+
if (typeof input.command === "string")
|
|
163
|
+
summary.command = redact(truncate(input.command, COMMAND_LIMIT));
|
|
164
|
+
if (typeof input.path === "string" && input.path.trim()) {
|
|
165
|
+
const shown = displayPath(input.path, cwd);
|
|
166
|
+
summary.path = shown.path;
|
|
167
|
+
summary.location = shown.location;
|
|
168
|
+
summary.exists = existsSync(resolve(cwd, input.path));
|
|
169
|
+
}
|
|
170
|
+
if (tool === "write" && typeof input.content === "string") {
|
|
171
|
+
summary.bytes = Buffer.byteLength(input.content, "utf8");
|
|
172
|
+
summary.excerpt = redact(truncate(input.content, EXCERPT_LIMIT));
|
|
173
|
+
}
|
|
174
|
+
if (tool === "edit" && Array.isArray(input.edits)) {
|
|
175
|
+
summary.editCount = input.edits.length;
|
|
176
|
+
summary.edits = input.edits.slice(0, 3).map(edit => {
|
|
177
|
+
const item = (edit ?? {});
|
|
178
|
+
return {
|
|
179
|
+
oldText: redact(truncate(typeof item.oldText === "string" ? item.oldText : "", EDIT_LIMIT)),
|
|
180
|
+
newText: redact(truncate(typeof item.newText === "string" ? item.newText : "", EDIT_LIMIT)),
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (summary.command === undefined && summary.path === undefined)
|
|
185
|
+
summary.input = redact(truncate(JSON.stringify(input), COMMAND_LIMIT));
|
|
186
|
+
return summary;
|
|
187
|
+
}
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// TypeSafe request: named state fields, three independent questions.
|
|
190
|
+
export const questions = {
|
|
191
|
+
irreversible: noul("Would executing `action` destroy, overwrite, or discard data or state that could not be recovered afterwards?", {
|
|
192
|
+
true: "Yes: it force-pushes or rewrites shared history, deletes untracked or uncommitted work, drops or truncates database objects, overwrites files outside the project, publishes or deploys, or removes something no build or checkout can regenerate.",
|
|
193
|
+
false: "No: it only reads, builds, tests, or creates new files, or it deletes generated output, caches, or temporary files that a build or checkout can recreate, or it is a change `task` explicitly asked for.",
|
|
194
|
+
}),
|
|
195
|
+
off_task: noul("Is `action` outside the scope of what `task` asks for?", {
|
|
196
|
+
true: "Yes: `task` does not call for this action, and it is not a normal preparatory, verification, or cleanup step for `task`.",
|
|
197
|
+
false: "No: `action` is a direct or reasonable step toward completing `task`.",
|
|
198
|
+
}),
|
|
199
|
+
scope: choice("How does `action` relate to `task`?", {
|
|
200
|
+
expected_step: "A step that `task` directly requires",
|
|
201
|
+
plausible_side_step: "Reasonable preparation, inspection, verification, or cleanup while doing `task`",
|
|
202
|
+
unrelated: "Not connected to `task`",
|
|
203
|
+
unclear: "`task` or `action` gives too little information to tell",
|
|
204
|
+
}),
|
|
205
|
+
};
|
|
206
|
+
export function buildRequest(summary, task) {
|
|
207
|
+
return {
|
|
208
|
+
state: {
|
|
209
|
+
task: task?.trim() ? truncate(task.trim(), TASK_LIMIT) : "(no user request recorded in this session)",
|
|
210
|
+
action: summary,
|
|
211
|
+
},
|
|
212
|
+
questions,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function combineSignals(timeoutMs, signal) {
|
|
216
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
217
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
218
|
+
}
|
|
219
|
+
const percent = (value) => value.toFixed(2);
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
export async function evaluateAction(action, options) {
|
|
222
|
+
const { config, judge } = options;
|
|
223
|
+
const summary = describeAction(action.tool, action.input, action.cwd);
|
|
224
|
+
if (!config.enabled || !config.tools.includes(action.tool)) {
|
|
225
|
+
return { level: "allow", source: "skipped", summary, patterns: [], reasons: [] };
|
|
226
|
+
}
|
|
227
|
+
const patterns = matchPatterns(action.tool, action.input, action.cwd);
|
|
228
|
+
const reasons = [];
|
|
229
|
+
let level = "allow";
|
|
230
|
+
for (const hit of patterns) {
|
|
231
|
+
level = higher(level, hit.severity === "destructive" ? "confirm" : "warn");
|
|
232
|
+
reasons.push(`${hit.severity}: ${hit.label}`);
|
|
233
|
+
}
|
|
234
|
+
if (summary.location === "outside_project") {
|
|
235
|
+
if (action.tool === "write" && summary.exists) {
|
|
236
|
+
level = higher(level, "confirm");
|
|
237
|
+
reasons.push("overwrites an existing file outside the project");
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
level = higher(level, "warn");
|
|
241
|
+
reasons.push(`${action.tool === "write" ? "creates" : "changes"} a file outside the project`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (action.tool === "bash" && patterns.length === 0 && typeof action.input.command === "string" && isReadOnlyCommand(action.input.command)) {
|
|
245
|
+
return { level, source: "read-only", summary, patterns, reasons };
|
|
246
|
+
}
|
|
247
|
+
if (!judge)
|
|
248
|
+
return { level, source: "pattern", summary, patterns, reasons };
|
|
249
|
+
try {
|
|
250
|
+
const result = await judge.evaluate(buildRequest(summary, action.task), { signal: combineSignals(config.timeoutMs, options.signal) });
|
|
251
|
+
const judgment = {
|
|
252
|
+
irreversible: result.answers.irreversible.noul,
|
|
253
|
+
offTask: result.answers.off_task.noul,
|
|
254
|
+
scope: result.answers.scope.choice,
|
|
255
|
+
scopeConfidence: result.answers.scope.confidence,
|
|
256
|
+
model: result.model,
|
|
257
|
+
elapsedMs: result.elapsedMs,
|
|
258
|
+
};
|
|
259
|
+
if (judgment.irreversible >= config.irreversible.confirm) {
|
|
260
|
+
level = higher(level, "confirm");
|
|
261
|
+
reasons.push(`irreversible ${percent(judgment.irreversible)}`);
|
|
262
|
+
}
|
|
263
|
+
else if (judgment.irreversible >= config.irreversible.warn) {
|
|
264
|
+
level = higher(level, "warn");
|
|
265
|
+
reasons.push(`possibly irreversible ${percent(judgment.irreversible)}`);
|
|
266
|
+
}
|
|
267
|
+
if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
|
|
268
|
+
level = higher(level, "confirm");
|
|
269
|
+
reasons.push(`off-task ${percent(judgment.offTask)} (unrelated to the request)`);
|
|
270
|
+
}
|
|
271
|
+
else if (judgment.offTask >= config.offTask.warn) {
|
|
272
|
+
level = higher(level, "warn");
|
|
273
|
+
reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
|
|
274
|
+
}
|
|
275
|
+
return { level, source: "typesafe", summary, patterns, reasons, judgment };
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
const known = error instanceof TypeSafeIntegrationError ? error : undefined;
|
|
279
|
+
const message = known?.message ?? "TypeSafe request failed.";
|
|
280
|
+
if (!config.failOpen) {
|
|
281
|
+
level = higher(level, "confirm");
|
|
282
|
+
reasons.push("TypeSafe unavailable and failOpen is false");
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
reasons.push("TypeSafe unavailable; allowed by failOpen");
|
|
286
|
+
}
|
|
287
|
+
return { level, source: "error", summary, patterns, reasons, error: message, ...(known ? { errorCode: known.code } : {}) };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** One-line rendering for widgets and logs. Includes no command text. */
|
|
291
|
+
export function formatVerdict(verdict) {
|
|
292
|
+
const parts = [`warden · ${verdict.summary.tool}`];
|
|
293
|
+
if (verdict.judgment)
|
|
294
|
+
parts.push(`irreversible ${percent(verdict.judgment.irreversible)}`, `off-task ${percent(verdict.judgment.offTask)}`, verdict.judgment.scope.replace(/_/g, " "));
|
|
295
|
+
if (verdict.patterns.length)
|
|
296
|
+
parts.push(`patterns: ${verdict.patterns.map(hit => hit.id).join(", ")}`);
|
|
297
|
+
if (verdict.source === "error")
|
|
298
|
+
parts.push("typesafe error");
|
|
299
|
+
if (verdict.source === "read-only")
|
|
300
|
+
parts.push("read-only");
|
|
301
|
+
parts.push(verdict.level);
|
|
302
|
+
return parts.join(" · ");
|
|
303
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, questions } from "./guard.js";
|
|
2
|
+
export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
|
|
3
|
+
export { redact } from "./redact.js";
|
|
4
|
+
export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
|
5
|
+
export type { ActionGuardConfig, Threshold, WardenConfig, LoadOptions } from "./config.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, questions } from "./guard.js";
|
|
2
|
+
export { redact } from "./redact.js";
|
|
3
|
+
export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function redact(text: string): string;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const REPLACEMENT = "[redacted]";
|
|
2
|
+
/** Best-effort credential scrubbing for text that leaves the machine. Ordered: multi-token shapes before bare tokens. */
|
|
3
|
+
const RULES = [
|
|
4
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, REPLACEMENT],
|
|
5
|
+
[/(authorization\s*[:=]\s*)(?:basic|bearer|token)?\s*\S+/gi, `$1${REPLACEMENT}`],
|
|
6
|
+
[/\b(bearer\s+)\S+/gi, `$1${REPLACEMENT}`],
|
|
7
|
+
[/((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|private[_-]?key|passw(?:or)?d|passphrase|token|secret|credentials?)[a-z0-9_-]*\s*[=:]\s*["']?)([^\s"'&;]+)/gi, `$1${REPLACEMENT}`],
|
|
8
|
+
[/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi, `$1${REPLACEMENT}@`],
|
|
9
|
+
[/\bsk-[A-Za-z0-9_-]{8,}/g, REPLACEMENT],
|
|
10
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, REPLACEMENT],
|
|
11
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, REPLACEMENT],
|
|
12
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, REPLACEMENT],
|
|
13
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, REPLACEMENT],
|
|
14
|
+
[/\bAIza[0-9A-Za-z_-]{30,}/g, REPLACEMENT],
|
|
15
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, REPLACEMENT],
|
|
16
|
+
];
|
|
17
|
+
export function redact(text) {
|
|
18
|
+
let out = text;
|
|
19
|
+
for (const [pattern, replacement] of RULES)
|
|
20
|
+
out = out.replace(pattern, replacement);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-warden",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Harness guardrails for Pi built on pi-typesafe: pattern checks plus Jev judgments flag irreversible or off-task bash, write, and edit calls before they run.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ryan Gapac",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/DevMortimer/pi-warden.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/DevMortimer/pi-warden#readme",
|
|
13
|
+
"bugs": "https://github.com/DevMortimer/pi-warden/issues",
|
|
14
|
+
"funding": "https://github.com/sponsors/DevMortimer",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"typesafe",
|
|
19
|
+
"jev",
|
|
20
|
+
"guardrails",
|
|
21
|
+
"harness-engineering"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./extension": {
|
|
32
|
+
"types": "./dist/extension.d.ts",
|
|
33
|
+
"import": "./dist/extension.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"extensions",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/index.js"
|
|
46
|
+
],
|
|
47
|
+
"image": "https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.build.json",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "node --import tsx --test tests/*.test.ts",
|
|
53
|
+
"test:live": "node --env-file-if-exists=.env scripts/live-smoke.mjs",
|
|
54
|
+
"dev:pi": "node scripts/dev-pi.mjs",
|
|
55
|
+
"check": "npm run typecheck && npm test && npm run build",
|
|
56
|
+
"prepack": "npm run build"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": ">=0.85.1 <1",
|
|
60
|
+
"@earendil-works/pi-tui": ">=0.85.1 <1"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@earendil-works/pi-coding-agent": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@earendil-works/pi-tui": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
72
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
73
|
+
"@types/node": "^22.0.0",
|
|
74
|
+
"tsx": "^4.23.13",
|
|
75
|
+
"typescript": "^6.0.0"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"pi-typesafe": "^0.3.0"
|
|
79
|
+
}
|
|
80
|
+
}
|