pi-warden 0.26.0 → 0.34.1
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/CHANGELOG.md +224 -0
- package/README.md +58 -174
- package/dist/audit.d.ts +22 -0
- package/dist/audit.js +110 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +57 -0
- package/dist/conscience-config.d.ts +21 -0
- package/dist/conscience-config.js +82 -0
- package/dist/conscience-loader.d.ts +26 -0
- package/dist/conscience-loader.js +82 -0
- package/dist/conscience-policy.d.ts +24 -0
- package/dist/conscience-policy.js +28 -0
- package/dist/conscience-privacy.d.ts +4 -0
- package/dist/conscience-privacy.js +11 -0
- package/dist/discover.d.ts +14 -0
- package/dist/discover.js +85 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +346 -61
- package/dist/extension.js.bak +346 -61
- package/dist/guard.d.ts +132 -1
- package/dist/guard.js +452 -31
- package/dist/holds.d.ts +2 -0
- package/dist/holds.js +7 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.js +8 -4
- package/dist/init.d.ts +36 -0
- package/dist/init.js +199 -0
- package/dist/laya-download.d.ts +40 -0
- package/dist/laya-download.js +288 -0
- package/dist/laya-judge.d.ts +62 -0
- package/dist/laya-judge.js +495 -0
- package/dist/learning.d.ts +81 -5
- package/dist/learning.js +268 -35
- package/dist/output.d.ts +31 -1
- package/dist/output.js +80 -1
- package/dist/panel.d.ts +29 -0
- package/dist/panel.js +257 -10
- package/dist/redact.js +9 -4
- package/dist/rules-file.d.ts +18 -0
- package/dist/rules-file.js +72 -0
- package/dist/rules.d.ts +8 -0
- package/dist/rules.js +54 -3
- package/dist/saver.d.ts +2 -0
- package/dist/saver.js +4 -0
- package/dist/shape.js +11 -2
- package/dist/tool-recommendation.d.ts +15 -0
- package/dist/tool-recommendation.js +31 -0
- package/dist/trace.d.ts +2 -0
- package/dist/widget.d.ts +98 -0
- package/dist/widget.js +262 -1
- package/package.json +2 -2
package/dist/audit.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { redact } from "./redact.js";
|
|
4
|
+
import { discoverSourceFiles, findProjects } from "./discover.js";
|
|
5
|
+
export { discoverSourceFiles, findProjects } from "./discover.js";
|
|
6
|
+
// ─── Prompt builder ─────────────────────────────────────────────────────────
|
|
7
|
+
/**
|
|
8
|
+
* Build the user message that sends the workspace to the session model for an
|
|
9
|
+
* agent-driven audit. The model reads source, finds Jev opportunities, produces
|
|
10
|
+
* measurable evidence, and writes an HTML report.
|
|
11
|
+
*
|
|
12
|
+
* `projects` comes from {@link findProjects}. `cwd` is the workspace root.
|
|
13
|
+
*/
|
|
14
|
+
export function buildAuditPrompt(cwd, projects) {
|
|
15
|
+
const projectName = cwd.split("/").pop() ?? "workspace";
|
|
16
|
+
// Collect project summaries so the model knows the workspace shape.
|
|
17
|
+
const summaries = [];
|
|
18
|
+
for (const projectPath of projects) {
|
|
19
|
+
const name = projectPath.split("/").pop() ?? projectPath;
|
|
20
|
+
let files;
|
|
21
|
+
try {
|
|
22
|
+
files = discoverSourceFiles(projectPath);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.warn(`audit: could not discover files in ${redact(projectPath)}: ${err instanceof Error ? err.message : err}`);
|
|
26
|
+
files = [];
|
|
27
|
+
}
|
|
28
|
+
const manifests = ["package.json", "Cargo.toml", "pyproject.toml", "go.mod"];
|
|
29
|
+
let manifest = "";
|
|
30
|
+
for (const m of manifests) {
|
|
31
|
+
const p = join(projectPath, m);
|
|
32
|
+
if (existsSync(p)) {
|
|
33
|
+
manifest = m;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
summaries.push(`- ${name} (${files.length} source files${manifest ? `, manifest: ${manifest}` : ""})`);
|
|
38
|
+
}
|
|
39
|
+
const reportPath = join(cwd, ".pi-warden", "audit-report.html");
|
|
40
|
+
return [
|
|
41
|
+
`# Workspace Audit — ${projectName}`,
|
|
42
|
+
"",
|
|
43
|
+
"You are auditing this workspace to find concrete places where hand-written heuristics can be replaced by typed Jev (TypeSafe) questions.",
|
|
44
|
+
"",
|
|
45
|
+
"## Workspace inventory",
|
|
46
|
+
summaries.join("\n"),
|
|
47
|
+
"",
|
|
48
|
+
"## What to do",
|
|
49
|
+
"",
|
|
50
|
+
"1. **Read the actual source** of each project. Do not rely on README or manifest alone. Open the files listed above (and any others you discover) and read the code.",
|
|
51
|
+
"",
|
|
52
|
+
"2. **Find concrete replacement candidates.** Look for:",
|
|
53
|
+
" - if/else or switch chains that classify, route, or decide",
|
|
54
|
+
" - regex classifiers or keyword lists that label text",
|
|
55
|
+
" - threshold tables or scoring heuristics",
|
|
56
|
+
" - ranking or sorting by hand-built criteria",
|
|
57
|
+
" - validation logic that checks correctness of free-form output",
|
|
58
|
+
" - routing decisions based on intent or content",
|
|
59
|
+
"",
|
|
60
|
+
" For each candidate, cite the exact **file:line** location.",
|
|
61
|
+
"",
|
|
62
|
+
"3. **Produce measurable data for every finding.** For each candidate, gather evidence such as:",
|
|
63
|
+
" - Count the branches, keyword entries, or cases the current code handles",
|
|
64
|
+
" - Count cases it misses (e.g. grep for keywords it does not cover, list known false positives from comments or issues)",
|
|
65
|
+
" - Run the existing test suite and count pass/fail relevant to this code path",
|
|
66
|
+
" - Write a throwaway script under the OS temp directory that feeds sample inputs through both the current code and a Jev question (using `typesafe_evaluate` when available in the session; this tool requires the operator to run `/typesafe enable` once per session — if a call fails with \"TypeSafe is disabled\", ask the operator once, and if it remains disabled, continue with the other evidence and mark those findings unmeasured)",
|
|
67
|
+
"",
|
|
68
|
+
" A finding without measurable comparison data must be marked **\"unmeasured\"** — do not dress it up.",
|
|
69
|
+
"",
|
|
70
|
+
"4. **Rank findings** by expected improvement (high/medium/low) and setup effort (low/medium/high).",
|
|
71
|
+
"",
|
|
72
|
+
`5. **Write a self-contained HTML report** to \`${reportPath}\` (create the \`.pi-warden\` directory if it does not exist).`,
|
|
73
|
+
" - Plain HTML + inline CSS, no external assets, no scripts that fetch anything.",
|
|
74
|
+
" - Sections: summary, findings table, one section per finding with evidence, methodology, and what was not measured.",
|
|
75
|
+
" - One short chat line at the end naming the file path and the finding count.",
|
|
76
|
+
"",
|
|
77
|
+
"## Constraints",
|
|
78
|
+
"",
|
|
79
|
+
"- Read-only: do not modify any project source while auditing. The only files you may write are the report and throwaway scripts under the OS temp directory.",
|
|
80
|
+
"- Be specific. Every finding must name a file:line in this workspace.",
|
|
81
|
+
"- Prefer fewer, well-evidenced findings over many thin ones.",
|
|
82
|
+
"- If no replacement opportunities exist in a project, say so briefly and move on.",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Take a point-in-time snapshot of the audit report file. Call before
|
|
87
|
+
* sending the prompt and again after the agent finishes.
|
|
88
|
+
*/
|
|
89
|
+
export function snapshotReport(reportPath) {
|
|
90
|
+
if (!existsSync(reportPath))
|
|
91
|
+
return { exists: false, mtimeMs: 0 };
|
|
92
|
+
try {
|
|
93
|
+
const s = statSync(reportPath);
|
|
94
|
+
return { exists: true, mtimeMs: s.mtimeMs };
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.warn(`audit: could not stat report: ${err instanceof Error ? err.message : err}`);
|
|
98
|
+
return { exists: false, mtimeMs: 0 };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Determine whether the audit produced a new or updated report.
|
|
103
|
+
*/
|
|
104
|
+
export function reportOutcome(before, after) {
|
|
105
|
+
if (!after.exists)
|
|
106
|
+
return "missing";
|
|
107
|
+
if (!before.exists || after.mtimeMs > before.mtimeMs)
|
|
108
|
+
return "written";
|
|
109
|
+
return "stale";
|
|
110
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface ActionGuardConfig {
|
|
|
41
41
|
intentMismatch: number;
|
|
42
42
|
/** The same, for a command whose effect is visible outside the working tree (commit, push, merge, publish, launch): less mismatch is enough. */
|
|
43
43
|
visibleMismatch: number;
|
|
44
|
+
/** Low P(should_proceed) is trace-only unless steer is enabled; hold is the inclusive threshold, not a blocking decision. Calibration: AUC 0.26 against regret, 44% flagged at 0.6 (100 targeted sessions, 2026-09-20). */
|
|
45
|
+
shouldProceed: {
|
|
46
|
+
hold: number;
|
|
47
|
+
steer: boolean;
|
|
48
|
+
};
|
|
44
49
|
/** Write each judged call and what the user did next (approved, declined, re-planned, regretted) to an owner-only per-session file under the agent directory; redacted, never the command. */
|
|
45
50
|
feedbackLog: boolean;
|
|
46
51
|
/** User-defined command rules (user file only; project files cannot set severity above warn). */
|
|
@@ -53,6 +58,11 @@ export interface ActionGuardConfig {
|
|
|
53
58
|
pathRules: PathRule[];
|
|
54
59
|
/** User-defined arming rules: editing files matching globs arms a command pattern for a window (user file only). */
|
|
55
60
|
armingRules: ArmingRule[];
|
|
61
|
+
/** Jev confidence at or above this escalates a violation's severity in the blast-radius and rules-guard escalation paths. */
|
|
62
|
+
escalationThreshold: number;
|
|
63
|
+
/** When a judge answers: "evidence" (default) lets the judge decide the level from irreversible score alone, using built-in pattern hits as context; "level" restores the legacy behaviour where the floor sets the level before the judge.
|
|
64
|
+
* User-declared rules keep their declared action in both modes. */
|
|
65
|
+
floor: "evidence" | "level";
|
|
56
66
|
}
|
|
57
67
|
/** A user-defined path rule: which paths, which side of the access is held, which tools, what happens on a hit. */
|
|
58
68
|
export interface PathRule {
|
|
@@ -213,6 +223,18 @@ export interface SubagentConfig {
|
|
|
213
223
|
export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
|
|
214
224
|
export declare function isRecallTool(value: unknown): value is RecallTool;
|
|
215
225
|
export type WardenMode = "steer" | "confirm" | "advise";
|
|
226
|
+
export interface LearningConfig {
|
|
227
|
+
/** Enable adaptive thresholds based on learning data. */
|
|
228
|
+
adaptiveThresholds: boolean;
|
|
229
|
+
/** Enable pattern analysis and recommendations. */
|
|
230
|
+
patternAnalysis: boolean;
|
|
231
|
+
/** Minimum number of holds before adaptive thresholds kick in. */
|
|
232
|
+
minHoldsForAdaptive: number;
|
|
233
|
+
/** How aggressively to adjust thresholds (0-1). Higher values mean faster adaptation. */
|
|
234
|
+
adaptationRate: number;
|
|
235
|
+
/** Days to keep hold records in SQLite before pruning. 0 disables pruning. */
|
|
236
|
+
retentionDays: number;
|
|
237
|
+
}
|
|
216
238
|
export interface WardenConfig {
|
|
217
239
|
/** Master switch. false disables every guard, including offline pattern checks. */
|
|
218
240
|
enabled: boolean;
|
|
@@ -251,6 +273,8 @@ export interface WardenConfig {
|
|
|
251
273
|
* steer costs at least one LLM turn, and a closing run that collects six notices collects six restatements of the final
|
|
252
274
|
* status. 0 disables the budget. Critical guards (stuck, done, runaway, subagent wake) always deliver. */
|
|
253
275
|
steerBudget: number;
|
|
276
|
+
/** Learning and adaptation settings. */
|
|
277
|
+
learning: LearningConfig;
|
|
254
278
|
}
|
|
255
279
|
export declare const PACKAGE_NAME = "pi-warden";
|
|
256
280
|
/** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
|
|
@@ -277,4 +301,8 @@ export declare function readUserConfig(): Json;
|
|
|
277
301
|
export declare function writeUserConfig(raw: Json): string;
|
|
278
302
|
/** Persists one top-level user setting without disturbing the rest of the file. */
|
|
279
303
|
export declare function setUserSetting(key: "typesafe" | "enabled" | "mode", value: boolean | WardenMode): string;
|
|
304
|
+
export declare function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown>;
|
|
305
|
+
export declare function getNestedValue(obj: Record<string, unknown>, path: string): unknown;
|
|
306
|
+
/** Coerce a CLI string into a JSON primitive so `/warden config set` stays ergonomic. */
|
|
307
|
+
export declare function parseConfigValue(value: string): unknown;
|
|
280
308
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -29,12 +29,15 @@ export function defaultConfig() {
|
|
|
29
29
|
offTask: { warn: 0.6, steer: 0.85 },
|
|
30
30
|
intentMismatch: 0.9,
|
|
31
31
|
visibleMismatch: 0.8,
|
|
32
|
+
shouldProceed: { hold: 0.6, steer: false },
|
|
32
33
|
feedbackLog: true,
|
|
33
34
|
commandRules: [],
|
|
34
35
|
commandDenyRules: [],
|
|
35
36
|
exemptRules: [],
|
|
36
37
|
pathRules: [],
|
|
37
38
|
armingRules: [],
|
|
39
|
+
escalationThreshold: 0.85,
|
|
40
|
+
floor: "evidence",
|
|
38
41
|
},
|
|
39
42
|
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
|
|
40
43
|
done: { enabled: true, claimsDone: 0.7, nudge: true },
|
|
@@ -49,6 +52,7 @@ export function defaultConfig() {
|
|
|
49
52
|
steerVisible: false,
|
|
50
53
|
notices: false,
|
|
51
54
|
steerBudget: 3,
|
|
55
|
+
learning: { adaptiveThresholds: true, patternAnalysis: true, minHoldsForAdaptive: 20, adaptationRate: 0.1, retentionDays: 365 },
|
|
52
56
|
};
|
|
53
57
|
}
|
|
54
58
|
/** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
|
|
@@ -250,6 +254,10 @@ function applyAction(base, raw, timeoutMs, source) {
|
|
|
250
254
|
offTask: offTaskThreshold(raw.offTask, base.offTask),
|
|
251
255
|
intentMismatch: probability(raw.intentMismatch, base.intentMismatch),
|
|
252
256
|
visibleMismatch: Math.min(probability(raw.visibleMismatch, base.visibleMismatch), probability(raw.intentMismatch, base.intentMismatch)),
|
|
257
|
+
shouldProceed: {
|
|
258
|
+
hold: probability(raw.shouldProceed?.hold, base.shouldProceed.hold),
|
|
259
|
+
steer: boolean(raw.shouldProceed?.steer, base.shouldProceed.steer),
|
|
260
|
+
},
|
|
253
261
|
feedbackLog: boolean(raw.feedbackLog, base.feedbackLog),
|
|
254
262
|
// Only the user file declares these; a project file cannot add, edit, or remove them. The base (already the
|
|
255
263
|
// user's rules when a project file layers on top) is carried through, so a project "action" block cannot wipe them.
|
|
@@ -260,6 +268,8 @@ function applyAction(base, raw, timeoutMs, source) {
|
|
|
260
268
|
pathRules: source === "user" ? parsePathRules(raw.pathRules) : base.pathRules,
|
|
261
269
|
// Arming rules are user-declared security policy: same gate.
|
|
262
270
|
armingRules: source === "user" ? parseArmingRules(raw.armingRules) : base.armingRules,
|
|
271
|
+
escalationThreshold: probability(raw.escalationThreshold, base.escalationThreshold),
|
|
272
|
+
floor: source === "user" && (raw.floor === "level" || raw.floor === "evidence") ? raw.floor : base.floor,
|
|
263
273
|
};
|
|
264
274
|
}
|
|
265
275
|
function applyStuck(base, raw) {
|
|
@@ -351,6 +361,7 @@ function applyWidget(base, raw) {
|
|
|
351
361
|
return {
|
|
352
362
|
enabled: boolean(raw.enabled, base.enabled),
|
|
353
363
|
placement: raw.placement === "belowEditor" || raw.placement === "aboveEditor" ? raw.placement : base.placement,
|
|
364
|
+
barMode: raw.barMode === "live" || raw.barMode === "stack" ? raw.barMode : base.barMode,
|
|
354
365
|
shortcut: typeof raw.shortcut === "string" ? raw.shortcut.trim() : base.shortcut,
|
|
355
366
|
panelWidth: typeof raw.panelWidth === "number" && Number.isSafeInteger(raw.panelWidth) && raw.panelWidth >= 20 ? raw.panelWidth
|
|
356
367
|
: typeof raw.panelWidth === "string" && /^[1-9]\d?%$/.test(raw.panelWidth.trim()) ? raw.panelWidth.trim() : base.panelWidth,
|
|
@@ -414,6 +425,18 @@ export function applyUserOverrides(base, raw) {
|
|
|
414
425
|
steerVisible: boolean(raw.steerVisible, base.steerVisible),
|
|
415
426
|
notices: boolean(raw.notices, base.notices),
|
|
416
427
|
steerBudget: typeof raw.steerBudget === "number" && Number.isInteger(raw.steerBudget) && raw.steerBudget >= 0 ? raw.steerBudget : base.steerBudget,
|
|
428
|
+
learning: applyLearning(base.learning, raw.learning),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function applyLearning(base, raw) {
|
|
432
|
+
if (!isObject(raw))
|
|
433
|
+
return base;
|
|
434
|
+
return {
|
|
435
|
+
adaptiveThresholds: boolean(raw.adaptiveThresholds, base.adaptiveThresholds),
|
|
436
|
+
patternAnalysis: boolean(raw.patternAnalysis, base.patternAnalysis),
|
|
437
|
+
minHoldsForAdaptive: Math.max(5, positiveInteger(raw.minHoldsForAdaptive, base.minHoldsForAdaptive)),
|
|
438
|
+
adaptationRate: Math.max(0, Math.min(1, probability(raw.adaptationRate, base.adaptationRate))),
|
|
439
|
+
retentionDays: Math.max(0, positiveInteger(raw.retentionDays, base.retentionDays)),
|
|
417
440
|
};
|
|
418
441
|
}
|
|
419
442
|
/** Project files may tune the guards but cannot grant TypeSafe consent, change the mode, or raise budgets. */
|
|
@@ -445,3 +468,37 @@ export function writeUserConfig(raw) {
|
|
|
445
468
|
export function setUserSetting(key, value) {
|
|
446
469
|
return writeUserConfig({ ...readUserConfig(), [key]: value });
|
|
447
470
|
}
|
|
471
|
+
export function setNestedValue(obj, path, value) {
|
|
472
|
+
const keys = path.split(".");
|
|
473
|
+
const result = { ...obj };
|
|
474
|
+
let current = result;
|
|
475
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
476
|
+
const key = keys[i];
|
|
477
|
+
current[key] = { ...(current[key] ?? {}) };
|
|
478
|
+
current = current[key];
|
|
479
|
+
}
|
|
480
|
+
current[keys.at(-1)] = value;
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
export function getNestedValue(obj, path) {
|
|
484
|
+
const keys = path.split(".");
|
|
485
|
+
let current = obj;
|
|
486
|
+
for (const key of keys) {
|
|
487
|
+
if (typeof current !== "object" || current === null)
|
|
488
|
+
return undefined;
|
|
489
|
+
current = current[key];
|
|
490
|
+
}
|
|
491
|
+
return current;
|
|
492
|
+
}
|
|
493
|
+
/** Coerce a CLI string into a JSON primitive so `/warden config set` stays ergonomic. */
|
|
494
|
+
export function parseConfigValue(value) {
|
|
495
|
+
if (value === "true")
|
|
496
|
+
return true;
|
|
497
|
+
if (value === "false")
|
|
498
|
+
return false;
|
|
499
|
+
if (value === "null")
|
|
500
|
+
return null;
|
|
501
|
+
if (/^-?\d+(\.\d+)?$/.test(value))
|
|
502
|
+
return Number(value);
|
|
503
|
+
return value;
|
|
504
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ConscienceConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
skills: {
|
|
4
|
+
mode: "off" | "recommend" | "load";
|
|
5
|
+
exclude: string[];
|
|
6
|
+
};
|
|
7
|
+
tools: {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
exclude: string[];
|
|
10
|
+
};
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
maxAssessments: number;
|
|
13
|
+
maxNudges: number;
|
|
14
|
+
maxSkillBytes: number;
|
|
15
|
+
maxLoadedBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const conscienceWarnings: (config: ConscienceConfig) => readonly string[];
|
|
18
|
+
export declare function disabledConscience(): ConscienceConfig;
|
|
19
|
+
export declare function validConscience(value: unknown): value is ConscienceConfig;
|
|
20
|
+
/** Normalize each raw layer before defaults lose the distinction between absent and explicit opt-outs. */
|
|
21
|
+
export declare function mergeConscience(base: ConscienceConfig, layer: Record<string, unknown>, project: boolean): ConscienceConfig;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const ranges = { timeoutMs: [100, 10000], maxAssessments: [1, 10], maxNudges: [1, 5], maxSkillBytes: [1024, 131072], maxLoadedBytes: [1024, 262144] };
|
|
2
|
+
const modes = ["off", "recommend", "load"];
|
|
3
|
+
const warnings = new WeakMap();
|
|
4
|
+
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
const exclusions = (value) => Array.isArray(value) && value.every(item => typeof item === "string" && /^[A-Za-z0-9_.:*\-]+$/.test(item));
|
|
6
|
+
export const conscienceWarnings = (config) => warnings.get(config) ?? [];
|
|
7
|
+
export function disabledConscience() {
|
|
8
|
+
return { enabled: false, skills: { mode: "off", exclude: [] }, tools: { enabled: false, exclude: [] }, timeoutMs: 1500, maxAssessments: 3, maxNudges: 2, maxSkillBytes: 32768, maxLoadedBytes: 65536 };
|
|
9
|
+
}
|
|
10
|
+
export function validConscience(value) {
|
|
11
|
+
if (!object(value) || typeof value.enabled !== "boolean" || !object(value.skills) || !object(value.tools))
|
|
12
|
+
return false;
|
|
13
|
+
if (!modes.includes(value.skills.mode) || !exclusions(value.skills.exclude) || typeof value.tools.enabled !== "boolean" || !exclusions(value.tools.exclude))
|
|
14
|
+
return false;
|
|
15
|
+
return Object.entries(ranges).every(([key, [min, max]]) => typeof value[key] === "number" && Number.isInteger(value[key]) && value[key] >= min && value[key] <= max);
|
|
16
|
+
}
|
|
17
|
+
/** Normalize each raw layer before defaults lose the distinction between absent and explicit opt-outs. */
|
|
18
|
+
export function mergeConscience(base, layer, project) {
|
|
19
|
+
const notes = [...conscienceWarnings(base)];
|
|
20
|
+
const warn = (key) => notes.push(`Invalid conscience configuration: ${key}; prior policy retained or capability disabled.`);
|
|
21
|
+
const raw = object(layer.conscience) ? layer.conscience : {};
|
|
22
|
+
if (layer.conscience !== undefined && !object(layer.conscience))
|
|
23
|
+
warn("conscience");
|
|
24
|
+
const next = { ...base, skills: { ...base.skills, exclude: [...base.skills.exclude] }, tools: { ...base.tools, exclude: [...base.tools.exclude] } };
|
|
25
|
+
const action = object(layer.action) ? layer.action : {};
|
|
26
|
+
const legacyOff = action.enabled === false || action.toolRecommendation === false;
|
|
27
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean")
|
|
28
|
+
warn("conscience.enabled");
|
|
29
|
+
const explicit = typeof raw.enabled === "boolean";
|
|
30
|
+
const enabled = explicit ? raw.enabled : legacyOff ? false : base.enabled;
|
|
31
|
+
next.enabled = project ? base.enabled && enabled : enabled;
|
|
32
|
+
if (layer.enabled === false)
|
|
33
|
+
next.enabled = false;
|
|
34
|
+
if (explicit && (legacyOff || layer.enabled === false))
|
|
35
|
+
notes.push("Conscience migration: conscience.enabled supersedes action.enabled/action.toolRecommendation; enabled remains the master opt-out.");
|
|
36
|
+
for (const category of ["skills", "tools"]) {
|
|
37
|
+
const value = raw[category];
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
if (!object(value)) {
|
|
41
|
+
warn(`conscience.${category}`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (category === "skills" && value.mode !== undefined) {
|
|
45
|
+
if (modes.includes(value.mode)) {
|
|
46
|
+
const index = modes.indexOf(value.mode);
|
|
47
|
+
next.skills.mode = modes[project ? Math.min(index, modes.indexOf(base.skills.mode)) : index];
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
warn("conscience.skills.mode");
|
|
51
|
+
}
|
|
52
|
+
if (category === "tools" && value.enabled !== undefined) {
|
|
53
|
+
if (typeof value.enabled === "boolean")
|
|
54
|
+
next.tools.enabled = project ? base.tools.enabled && value.enabled : value.enabled;
|
|
55
|
+
else
|
|
56
|
+
warn("conscience.tools.enabled");
|
|
57
|
+
}
|
|
58
|
+
if (value.exclude !== undefined) {
|
|
59
|
+
if (exclusions(value.exclude))
|
|
60
|
+
next[category].exclude = [...new Set([...(project ? base[category].exclude : []), ...value.exclude])];
|
|
61
|
+
else {
|
|
62
|
+
warn(`conscience.${category}.exclude`);
|
|
63
|
+
if (category === "skills")
|
|
64
|
+
next.skills.mode = "off";
|
|
65
|
+
else
|
|
66
|
+
next.tools.enabled = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const key of Object.keys(ranges)) {
|
|
71
|
+
const value = raw[key];
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const [min, max] = ranges[key];
|
|
75
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max)
|
|
76
|
+
next[key] = project ? Math.min(base[key], value) : value;
|
|
77
|
+
else
|
|
78
|
+
warn(`conscience.${key}`);
|
|
79
|
+
}
|
|
80
|
+
warnings.set(next, [...new Set(notes)]);
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ActionGuardConfig } from "./config.js";
|
|
3
|
+
export interface SkillReadRequest {
|
|
4
|
+
skill: Skill;
|
|
5
|
+
cwd: string;
|
|
6
|
+
action: ActionGuardConfig;
|
|
7
|
+
maxBytes: number;
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
disclosed: string;
|
|
10
|
+
}
|
|
11
|
+
export type SkillReadResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
body: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
digest: string;
|
|
16
|
+
notice: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
reason: "load_denied" | "load_too_large" | "load_changed" | "load_failed" | "metadata_unsafe" | "timeout";
|
|
20
|
+
};
|
|
21
|
+
export declare function skillPathPolicy(skill: Skill, cwd: string, action: ActionGuardConfig, resolved?: string): {
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
notice: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Narrow catalog read, not an agent tool call and not a filesystem sandbox. */
|
|
26
|
+
export declare function readTrustedSkill(request: SkillReadRequest): Promise<SkillReadResult>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { parseFrontmatter, stripFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { matchPathRules } from "./guard.js";
|
|
6
|
+
import { digest } from "./conscience-policy.js";
|
|
7
|
+
import { safeConscienceText } from "./conscience-privacy.js";
|
|
8
|
+
export function skillPathPolicy(skill, cwd, action, resolved) {
|
|
9
|
+
const hits = [skill.filePath, ...(resolved ? [resolved] : [])].flatMap(path => matchPathRules("read", { path }, cwd, action.pathRules, new Set(action.exemptRules)));
|
|
10
|
+
return { allowed: !hits.some(hit => hit.severity === "deny" || hit.severity === "destructive"), notice: hits.length > 0 };
|
|
11
|
+
}
|
|
12
|
+
/** Narrow catalog read, not an agent tool call and not a filesystem sandbox. */
|
|
13
|
+
export async function readTrustedSkill(request) {
|
|
14
|
+
const { skill, cwd, action, signal, maxBytes } = request;
|
|
15
|
+
const check = () => signal.throwIfAborted();
|
|
16
|
+
const fingerprint = (value) => [value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs].join(":");
|
|
17
|
+
try {
|
|
18
|
+
check();
|
|
19
|
+
if (!skillPathPolicy(skill, cwd, action).allowed)
|
|
20
|
+
return { ok: false, reason: "load_denied" };
|
|
21
|
+
const advertised = resolve(cwd, skill.filePath);
|
|
22
|
+
const target = await realpath(advertised);
|
|
23
|
+
check();
|
|
24
|
+
const base = await realpath(resolve(cwd, skill.baseDir));
|
|
25
|
+
check();
|
|
26
|
+
const policy = skillPathPolicy(skill, cwd, action, target);
|
|
27
|
+
if (!policy.allowed)
|
|
28
|
+
return { ok: false, reason: "load_denied" };
|
|
29
|
+
const before = await stat(target);
|
|
30
|
+
check();
|
|
31
|
+
if (!before.isFile())
|
|
32
|
+
return { ok: false, reason: "load_failed" };
|
|
33
|
+
if (before.size > maxBytes)
|
|
34
|
+
return { ok: false, reason: "load_too_large" };
|
|
35
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
36
|
+
try {
|
|
37
|
+
check();
|
|
38
|
+
const opened = await handle.stat();
|
|
39
|
+
check();
|
|
40
|
+
if (!opened.isFile() || fingerprint(before) !== fingerprint(opened))
|
|
41
|
+
return { ok: false, reason: "load_changed" };
|
|
42
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
43
|
+
let count = 0;
|
|
44
|
+
while (count < buffer.length) {
|
|
45
|
+
const result = await handle.read(buffer, count, buffer.length - count, count);
|
|
46
|
+
check();
|
|
47
|
+
if (!result.bytesRead)
|
|
48
|
+
break;
|
|
49
|
+
count += result.bytesRead;
|
|
50
|
+
}
|
|
51
|
+
if (count > maxBytes)
|
|
52
|
+
return { ok: false, reason: "load_too_large" };
|
|
53
|
+
const after = await handle.stat();
|
|
54
|
+
check();
|
|
55
|
+
const currentTarget = await realpath(advertised);
|
|
56
|
+
check();
|
|
57
|
+
const currentBase = await realpath(resolve(cwd, skill.baseDir));
|
|
58
|
+
check();
|
|
59
|
+
const current = await stat(target);
|
|
60
|
+
check();
|
|
61
|
+
if (currentTarget !== target || currentBase !== base || fingerprint(opened) !== fingerprint(after) || fingerprint(after) !== fingerprint(current) || count !== after.size)
|
|
62
|
+
return { ok: false, reason: "load_changed" };
|
|
63
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, count));
|
|
64
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text))
|
|
65
|
+
return { ok: false, reason: "load_failed" };
|
|
66
|
+
const { frontmatter } = parseFrontmatter(text);
|
|
67
|
+
if ((frontmatter.name ?? basename(skill.baseDir)) !== skill.name || frontmatter.description !== skill.description || frontmatter["disable-model-invocation"] === true)
|
|
68
|
+
return { ok: false, reason: "load_changed" };
|
|
69
|
+
const body = stripFrontmatter(text);
|
|
70
|
+
if (safeConscienceText(text, request.disclosed) !== text)
|
|
71
|
+
return { ok: false, reason: "metadata_unsafe" };
|
|
72
|
+
check();
|
|
73
|
+
return { ok: true, body, bytes: count, digest: digest(text), notice: policy.notice };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { ok: false, reason: signal.aborted ? "timeout" : "load_failed" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const SCORE_LEVELS: string[];
|
|
2
|
+
export declare const SCORE_INSTRUCTIONS = "Assess next-step usefulness of the named candidate against task, recent context, observations, supplied capabilities and constraints. A supplied workflow must not be replaced; another skill must support a new unmet need. Conversation methods may be useful before asking the user a question. An appropriate ordinary tool does not exclude a complementary skill.";
|
|
3
|
+
export declare const DISPOSITION: {
|
|
4
|
+
type: "choice";
|
|
5
|
+
instructions: string;
|
|
6
|
+
criteria: {
|
|
7
|
+
advance: string;
|
|
8
|
+
awaiting_user: string;
|
|
9
|
+
no_gap: string;
|
|
10
|
+
unclear: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare const CONSCIENCE_MODEL = "jev-1.13";
|
|
14
|
+
export declare const digest: (value: unknown) => string;
|
|
15
|
+
export declare const QUESTION_POLICY_HASH: string;
|
|
16
|
+
export interface MeasuredPolicy {
|
|
17
|
+
questionHash: string;
|
|
18
|
+
model: string;
|
|
19
|
+
recommendation: number;
|
|
20
|
+
loading: number;
|
|
21
|
+
evidence: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const measuredPolicy: MeasuredPolicy | undefined;
|
|
24
|
+
export declare function validPolicy(policy: MeasuredPolicy | undefined, model: string): policy is MeasuredPolicy;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const SCORE_LEVELS = [
|
|
3
|
+
"No useful contribution to the current request, or conflicts with the supplied constraints.",
|
|
4
|
+
"Related to the subject, but already covered, premature, or unable to resolve the current need.",
|
|
5
|
+
"Addresses a concrete unmet need at the current step without displacing the active workflow.",
|
|
6
|
+
"Supplies a missing prerequisite or directly applicable documented method needed for the current step.",
|
|
7
|
+
];
|
|
8
|
+
export const SCORE_INSTRUCTIONS = "Assess next-step usefulness of the named candidate against task, recent context, observations, supplied capabilities and constraints. A supplied workflow must not be replaced; another skill must support a new unmet need. Conversation methods may be useful before asking the user a question. An appropriate ordinary tool does not exclude a complementary skill.";
|
|
9
|
+
export const DISPOSITION = {
|
|
10
|
+
type: "choice",
|
|
11
|
+
instructions: "What is the current task disposition? An initial thinking request is not awaiting_user merely because clarification will be needed: its conversation method is an available next step. Awaiting_user requires an actual unanswered question from the assistant.",
|
|
12
|
+
criteria: {
|
|
13
|
+
advance: "A useful next step can be taken now.",
|
|
14
|
+
awaiting_user: "The assistant has already asked for information and must wait for the user.",
|
|
15
|
+
no_gap: "The request is satisfied with no useful missing step.",
|
|
16
|
+
unclear: "There is insufficient evidence to identify a useful next step.",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export const CONSCIENCE_MODEL = "jev-1.13";
|
|
20
|
+
export const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
21
|
+
export const QUESTION_POLICY_HASH = digest({ levels: SCORE_LEVELS, instructions: SCORE_INSTRUCTIONS, disposition: DISPOSITION, stateVersion: 1 });
|
|
22
|
+
// No shipping threshold exists until the frozen holdout and follow-through gates pass.
|
|
23
|
+
export const measuredPolicy = undefined;
|
|
24
|
+
export function validPolicy(policy, model) {
|
|
25
|
+
return !!policy && policy.questionHash === QUESTION_POLICY_HASH && policy.model === model && !!policy.evidence
|
|
26
|
+
&& Number.isFinite(policy.recommendation) && policy.recommendation >= 0.5 && policy.recommendation <= 0.95
|
|
27
|
+
&& Number.isFinite(policy.loading) && policy.loading >= policy.recommendation && policy.loading <= 0.95;
|
|
28
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** URLs are not local paths; protect them before scanning free text for filesystem spans. */
|
|
2
|
+
export declare function safeConscienceText(text: string, disclosed?: string): string;
|
|
3
|
+
export declare const safeCapabilityName: (name: string) => boolean;
|
|
4
|
+
export declare const excludedCapability: (name: string, patterns: readonly string[]) => boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { redact } from "./redact.js";
|
|
2
|
+
/** URLs are not local paths; protect them before scanning free text for filesystem spans. */
|
|
3
|
+
export function safeConscienceText(text, disclosed = "") {
|
|
4
|
+
const urls = [];
|
|
5
|
+
const redacted = redact(text);
|
|
6
|
+
const masked = redacted.replace(/https?:\/\/[^\s<>"'`]+/gi, value => `\u0001${urls.push(value) - 1}\u0002`);
|
|
7
|
+
const clean = masked.replace(/(?:file:\/\/[^\s<>"'`]+|[A-Za-z]:[\\/][^\s<>"'`]+|\\\\[^\s<>"'`]+|~\/[^\s<>"'`]+|(?<![\w/:])\/(?:[^\s<>"'`]+))/g, path => disclosed.includes(path) ? path : "[local path]");
|
|
8
|
+
return clean.replace(/\u0001(\d+)\u0002/g, (_, index) => urls[Number(index)] ?? "[URL]");
|
|
9
|
+
}
|
|
10
|
+
export const safeCapabilityName = (name) => /^[A-Za-z0-9_.:\-]{1,128}$/.test(name) && redact(name) === name;
|
|
11
|
+
export const excludedCapability = (name, patterns) => patterns.some(pattern => new RegExp(`^${pattern.split("*").map(part => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(name));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Directories to skip during source-file discovery. */
|
|
2
|
+
export declare const SKIP_DIRS: Set<string>;
|
|
3
|
+
export declare const SOURCE_RE: RegExp;
|
|
4
|
+
/**
|
|
5
|
+
* Recursively walk `dir` and collect source-file paths (relative to `dir`).
|
|
6
|
+
* Skips directories in SKIP_DIRS and stops after `max` files.
|
|
7
|
+
*/
|
|
8
|
+
export declare function discoverSourceFiles(dir: string, max?: number): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Find project directories inside a workspace. A project is any directory
|
|
11
|
+
* containing a known manifest file. The workspace root itself counts if it
|
|
12
|
+
* has one.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findProjects(cwd: string): string[];
|