@preapexis/piexis 0.2.2 → 0.2.4
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/AGENTS.md +21 -41
- package/README.md +73 -139
- package/extensions/agents.ts +205 -0
- package/extensions/ask-question.ts +54 -95
- package/extensions/recommended-tools.ts +62 -0
- package/extensions/ui.ts +204 -58
- package/lib/agent-config.ts +55 -0
- package/lib/agents.ts +208 -0
- package/lib/question-model.ts +98 -0
- package/lib/question-ui.ts +214 -0
- package/lib/recommended-tools.ts +119 -0
- package/lib/subagent-runner.ts +146 -0
- package/lib/ui-tips.json +41 -0
- package/package.json +5 -13
- package/prompts/init.md +10 -2
- package/prompts/summary.md +7 -6
- package/schemas/agent.schema.json +48 -0
- package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
- package/skills/caveman/LICENSE-MIT +21 -0
- package/skills/caveman/NOTICE +10 -0
- package/skills/caveman/SKILL.md +89 -0
- package/skills/find-skills/SKILL.md +141 -0
- package/skills/ponytail/LICENSE +21 -0
- package/skills/ponytail/NOTICE +9 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills-lock.json +6 -0
- package/tests/agents.test.mjs +605 -0
- package/tests/ask-question-pty.test.mjs +16 -0
- package/tests/ask-question.test.mjs +269 -0
- package/tests/fixtures/agents-provider.ts +55 -0
- package/tests/fixtures/ask-question-pty.py +91 -0
- package/tests/fixtures/ask-question-terminal.mjs +69 -0
- package/tests/fixtures/recommended-tools-terminal.mjs +103 -0
- package/tests/init-workflow.test.mjs +31 -0
- package/tests/recommended-tools-pty.test.mjs +16 -0
- package/tests/recommended-tools.test.mjs +423 -0
- package/tests/resources.test.mjs +66 -0
- package/tests/skills-routing.test.mjs +7 -4
- package/tests/ui-tip-reload.test.mjs +70 -0
- package/tests/ui.test.mjs +450 -0
- package/tsconfig.json +1 -0
- package/eval/README.md +0 -72
- package/eval/permission-cases.json +0 -28
- package/eval/permissions.mjs +0 -103
- package/extensions/background-tasks.ts +0 -433
- package/extensions/background-worker.ts +0 -50
- package/extensions/glob.ts +0 -32
- package/extensions/mode.ts +0 -397
- package/lib/bash-approvals.ts +0 -113
- package/lib/bash-policy.ts +0 -164
- package/lib/mode-policy.ts +0 -174
- package/lib/path-policy.ts +0 -182
- package/lib/permission-classifier.ts +0 -128
- package/lib/permission-config.ts +0 -91
- package/lib/permission-denials.ts +0 -32
- package/lib/permission-intent.ts +0 -128
- package/lib/permission-taxonomy.ts +0 -15
- package/lib/task-broker.ts +0 -65
- package/lib/task-checkout.ts +0 -39
- package/lib/task-git.ts +0 -44
- package/lib/task-patch.ts +0 -106
- package/lib/task-permissions.ts +0 -51
- package/lib/task-report.ts +0 -62
- package/lib/task-worktree-policy.ts +0 -56
- package/skills/claude-api/SKILL.md +0 -572
- package/skills/claude-api/csharp/claude-api/README.md +0 -362
- package/skills/claude-api/csharp/claude-api/batches.md +0 -14
- package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
- package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
- package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
- package/skills/claude-api/curl/examples.md +0 -256
- package/skills/claude-api/curl/managed-agents.md +0 -371
- package/skills/claude-api/go/claude-api/README.md +0 -188
- package/skills/claude-api/go/claude-api/files-api.md +0 -23
- package/skills/claude-api/go/claude-api/streaming.md +0 -43
- package/skills/claude-api/go/claude-api/tool-use.md +0 -220
- package/skills/claude-api/go/managed-agents/README.md +0 -564
- package/skills/claude-api/java/claude-api/README.md +0 -238
- package/skills/claude-api/java/claude-api/files-api.md +0 -27
- package/skills/claude-api/java/claude-api/streaming.md +0 -24
- package/skills/claude-api/java/claude-api/tool-use.md +0 -228
- package/skills/claude-api/java/managed-agents/README.md +0 -443
- package/skills/claude-api/php/claude-api/README.md +0 -174
- package/skills/claude-api/php/claude-api/batches.md +0 -15
- package/skills/claude-api/php/claude-api/files-api.md +0 -13
- package/skills/claude-api/php/claude-api/streaming.md +0 -27
- package/skills/claude-api/php/claude-api/tool-use.md +0 -253
- package/skills/claude-api/php/managed-agents/README.md +0 -443
- package/skills/claude-api/python/claude-api/README.md +0 -565
- package/skills/claude-api/python/claude-api/batches.md +0 -198
- package/skills/claude-api/python/claude-api/files-api.md +0 -170
- package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
- package/skills/claude-api/python/claude-api/streaming.md +0 -179
- package/skills/claude-api/python/claude-api/tool-use.md +0 -627
- package/skills/claude-api/python/managed-agents/README.md +0 -335
- package/skills/claude-api/ruby/claude-api/README.md +0 -134
- package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
- package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
- package/skills/claude-api/ruby/managed-agents/README.md +0 -394
- package/skills/claude-api/shared/admin-api.md +0 -179
- package/skills/claude-api/shared/agent-design.md +0 -101
- package/skills/claude-api/shared/anthropic-cli.md +0 -269
- package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
- package/skills/claude-api/shared/cost-optimization.md +0 -233
- package/skills/claude-api/shared/error-codes.md +0 -262
- package/skills/claude-api/shared/live-sources.md +0 -166
- package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
- package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
- package/skills/claude-api/shared/managed-agents-core.md +0 -359
- package/skills/claude-api/shared/managed-agents-environments.md +0 -226
- package/skills/claude-api/shared/managed-agents-events.md +0 -290
- package/skills/claude-api/shared/managed-agents-memory.md +0 -201
- package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
- package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
- package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
- package/skills/claude-api/shared/managed-agents-overview.md +0 -77
- package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
- package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
- package/skills/claude-api/shared/managed-agents-tools.md +0 -504
- package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
- package/skills/claude-api/shared/model-migration.md +0 -1872
- package/skills/claude-api/shared/models.md +0 -142
- package/skills/claude-api/shared/platform-availability.md +0 -53
- package/skills/claude-api/shared/prompt-audit.md +0 -223
- package/skills/claude-api/shared/prompt-caching.md +0 -292
- package/skills/claude-api/shared/token-counting.md +0 -56
- package/skills/claude-api/shared/tool-use-concepts.md +0 -526
- package/skills/claude-api/typescript/claude-api/README.md +0 -405
- package/skills/claude-api/typescript/claude-api/batches.md +0 -106
- package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
- package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
- package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
- package/skills/claude-api/typescript/managed-agents/README.md +0 -359
- package/tests/auto-permissions.test.mjs +0 -315
- package/tests/background-tasks.test.mjs +0 -27
- package/tests/background-worker.test.mjs +0 -60
- package/tests/bash-permissions.test.mjs +0 -276
- package/tests/glob.test.mjs +0 -31
- package/tests/mode.test.mjs +0 -62
- package/tests/permission-evaluation.test.mjs +0 -70
- package/tests/permission-foundation.test.mjs +0 -194
- package/tests/permission-policy.test.mjs +0 -158
- package/tests/runtime/README.md +0 -51
- package/tests/runtime/harness.mjs +0 -190
- package/tests/runtime/permissions.test.mjs +0 -192
- package/tests/runtime/tui.test.mjs +0 -60
- package/tests/task-integration.test.mjs +0 -324
- package/tests/task-permissions.test.mjs +0 -178
- package/tests/task-worktree-policy.test.mjs +0 -53
package/lib/permission-config.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export type PermissionConfig = {
|
|
6
|
-
version: 1;
|
|
7
|
-
initialized: true;
|
|
8
|
-
classifier?: { provider: string; model: string; timeoutMs: number };
|
|
9
|
-
/** Models permitted for isolated background tasks. An empty list disables them. */
|
|
10
|
-
background?: { models: string[] };
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
function validString(value: unknown): value is string {
|
|
14
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function validateConfig(value: unknown): PermissionConfig {
|
|
18
|
-
if (!value || typeof value !== "object") throw new Error("permission-modes.json must contain an object.");
|
|
19
|
-
const config = value as any;
|
|
20
|
-
if (config.version !== 1 || config.initialized !== true)
|
|
21
|
-
throw new Error("permission-modes.json must have version 1 and initialized: true.");
|
|
22
|
-
if (config.classifier !== undefined) {
|
|
23
|
-
const classifier = config.classifier;
|
|
24
|
-
if (!classifier || !validString(classifier.provider) || !validString(classifier.model) ||
|
|
25
|
-
!Number.isFinite(classifier.timeoutMs) || classifier.timeoutMs < 1000)
|
|
26
|
-
throw new Error("permission-modes.json has an invalid classifier configuration.");
|
|
27
|
-
}
|
|
28
|
-
if (config.background !== undefined) {
|
|
29
|
-
if (!config.background || !Array.isArray(config.background.models) ||
|
|
30
|
-
!config.background.models.every((model: unknown) => validString(model) && /^[^/]+\/.+$/.test(model)))
|
|
31
|
-
throw new Error("permission-modes.json has an invalid background.models list.");
|
|
32
|
-
}
|
|
33
|
-
return config as PermissionConfig;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function classifierModel(registry: any, current: any) {
|
|
37
|
-
const candidates = [current, ...registry.getAvailable()]
|
|
38
|
-
.filter(Boolean)
|
|
39
|
-
.filter((model, index, values) => values.findIndex((item) => item.provider === model.provider && item.id === model.id) === index)
|
|
40
|
-
.find((model) => model.input?.includes("text") && registry.hasConfiguredAuth(model));
|
|
41
|
-
return candidates;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function initializeProjectPermissionConfig(cwd: string, registry: any, currentModel: any): Promise<PermissionConfig> {
|
|
45
|
-
const configDir = join(cwd, CONFIG_DIR_NAME);
|
|
46
|
-
const configPath = join(configDir, "permission-modes.json");
|
|
47
|
-
const settingsPath = join(configDir, "settings.json");
|
|
48
|
-
await mkdir(configDir, { recursive: true });
|
|
49
|
-
try {
|
|
50
|
-
await readFile(settingsPath, "utf8");
|
|
51
|
-
} catch (error: any) {
|
|
52
|
-
if (error?.code !== "ENOENT") throw new Error(`Cannot read ${settingsPath}: ${error.message}`);
|
|
53
|
-
await writeFile(settingsPath, '{"quietStartup":true}\n', { flag: "wx" });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
return validateConfig(JSON.parse(await readFile(configPath, "utf8")));
|
|
58
|
-
} catch (error: any) {
|
|
59
|
-
if (error?.code !== "ENOENT") throw new Error(`Cannot read ${configPath}: ${error.message}`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const model = classifierModel(registry, currentModel);
|
|
63
|
-
const config: PermissionConfig = {
|
|
64
|
-
version: 1,
|
|
65
|
-
initialized: true,
|
|
66
|
-
...(model ? {
|
|
67
|
-
classifier: { provider: model.provider, model: model.id, timeoutMs: 15000 },
|
|
68
|
-
background: { models: [`${model.provider}/${model.id}`] }
|
|
69
|
-
} : {}),
|
|
70
|
-
};
|
|
71
|
-
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx" });
|
|
72
|
-
return config;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function defaultPermissionConfig(): PermissionConfig {
|
|
76
|
-
return { version: 1, initialized: true };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Read project configuration without making startup mutate the project. */
|
|
80
|
-
export async function readProjectPermissionConfig(cwd: string): Promise<PermissionConfig> {
|
|
81
|
-
const path = join(cwd, CONFIG_DIR_NAME, "permission-modes.json");
|
|
82
|
-
try {
|
|
83
|
-
return validateConfig(JSON.parse(await readFile(path, "utf8")));
|
|
84
|
-
} catch (error: any) {
|
|
85
|
-
if (error?.code === "ENOENT") return defaultPermissionConfig();
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** @deprecated Use initializeProjectPermissionConfig for explicit initialization only. */
|
|
91
|
-
export const ensureProjectPermissionConfig = initializeProjectPermissionConfig;
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
export type DenialLimits = { repeated: number; total: number };
|
|
4
|
-
export const DEFAULT_DENIAL_LIMITS: DenialLimits = { repeated: 3, total: 6 };
|
|
5
|
-
|
|
6
|
-
export function denialLimit(value: unknown, fallback: number): number {
|
|
7
|
-
if (value === undefined || value === false) return fallback;
|
|
8
|
-
if (typeof value !== "string" || !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 50)
|
|
9
|
-
throw new Error("Permission denial limits must be integers from 1 to 50.");
|
|
10
|
-
return Number(value);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Per user request, not per tool/turn. Successful calls do not erase denials. */
|
|
14
|
-
export class DenialTracker {
|
|
15
|
-
private actions = new Map<string, number>();
|
|
16
|
-
total = 0;
|
|
17
|
-
stopped = false;
|
|
18
|
-
revision = 0;
|
|
19
|
-
limits: DenialLimits;
|
|
20
|
-
constructor(limits: DenialLimits = DEFAULT_DENIAL_LIMITS) { this.limits = { ...limits }; }
|
|
21
|
-
reset(): void { this.actions.clear(); this.total = 0; this.stopped = false; this.revision++; }
|
|
22
|
-
deny(tool: string, input: unknown): { repeated: number; total: number; stopped: boolean } {
|
|
23
|
-
const key = createHash("sha256").update(tool).update(JSON.stringify(input) ?? "null").digest("hex");
|
|
24
|
-
const repeated = (this.actions.get(key) ?? 0) + 1;
|
|
25
|
-
if (!this.stopped) {
|
|
26
|
-
this.actions.set(key, repeated);
|
|
27
|
-
this.total++;
|
|
28
|
-
this.stopped = repeated >= this.limits.repeated || this.total >= this.limits.total;
|
|
29
|
-
}
|
|
30
|
-
return { repeated, total: this.total, stopped: this.stopped };
|
|
31
|
-
}
|
|
32
|
-
}
|
package/lib/permission-intent.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
export const INTENT_PROVENANCE = "piexis-input-provenance";
|
|
4
|
-
const MAX_MESSAGES = 16;
|
|
5
|
-
const MAX_MESSAGE_CHARS = 6000;
|
|
6
|
-
const MAX_CONTEXT_CHARS = 24000;
|
|
7
|
-
|
|
8
|
-
type InputSource = "interactive" | "rpc" | "extension";
|
|
9
|
-
type Message = { role: string; content?: unknown; timestamp?: number };
|
|
10
|
-
export type IntentEntry = {
|
|
11
|
-
id: string;
|
|
12
|
-
type: string;
|
|
13
|
-
customType?: string;
|
|
14
|
-
data?: unknown;
|
|
15
|
-
message?: Message;
|
|
16
|
-
targetId?: string;
|
|
17
|
-
};
|
|
18
|
-
export type IntentMessage = { id: string; source: "interactive" | "rpc"; text: string };
|
|
19
|
-
export type UserIntent = {
|
|
20
|
-
currentRequestId?: string;
|
|
21
|
-
messages: IntentMessage[];
|
|
22
|
-
complete: boolean;
|
|
23
|
-
issues: string[];
|
|
24
|
-
};
|
|
25
|
-
type Provenance = { version: 1; hash: string; timestamp: number; source: InputSource | "unknown" };
|
|
26
|
-
|
|
27
|
-
function textOf(content: unknown): string {
|
|
28
|
-
if (typeof content === "string") return content;
|
|
29
|
-
if (!Array.isArray(content)) return "";
|
|
30
|
-
return content.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
31
|
-
.map((part) => part.text).join("");
|
|
32
|
-
}
|
|
33
|
-
function hash(content: unknown): string {
|
|
34
|
-
return createHash("sha256").update(JSON.stringify(content) ?? "null").digest("hex");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Best effort only: arbitrary secrets cannot be reliably recognized in prose. */
|
|
38
|
-
export function redactIntentSecrets(text: string): string {
|
|
39
|
-
return text
|
|
40
|
-
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]")
|
|
41
|
-
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9+/_.=-]+/gi, "[REDACTED AUTHORIZATION]")
|
|
42
|
-
.replace(/\b(?:[A-Z_]*(?:API_KEY|TOKEN|PASSWORD|SECRET)|password|api[-_]?key)\s*[:=]\s*(?:"[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi, "[REDACTED SECRET]")
|
|
43
|
-
.replace(/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16})\b/g, "[REDACTED TOKEN]");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Observational provenance, not an isolation boundary against trusted extensions.
|
|
47
|
-
* Exact matching deliberately declines to authenticate expanded/transformed prompts.
|
|
48
|
-
* Persist only a digest and source, not a second copy of the user's prompt.
|
|
49
|
-
*/
|
|
50
|
-
export class IntentTracker {
|
|
51
|
-
private pending: { hash: string; source: InputSource }[] = [];
|
|
52
|
-
|
|
53
|
-
reset(): void { this.pending = []; }
|
|
54
|
-
get hasPendingInput(): boolean { return this.pending.length > 0; }
|
|
55
|
-
|
|
56
|
-
input(text: string, source: InputSource): void {
|
|
57
|
-
this.pending.push({ hash: hash(text), source });
|
|
58
|
-
// Overflow drops provenance rather than guessing an origin.
|
|
59
|
-
if (this.pending.length > 64) this.pending = [];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
message(message: Message): Provenance | undefined {
|
|
63
|
-
if (message.role !== "user") return undefined;
|
|
64
|
-
const textHash = hash(textOf(message.content));
|
|
65
|
-
const matches = this.pending.filter((item) => item.hash === textHash);
|
|
66
|
-
this.pending = this.pending.filter((item) => item.hash !== textHash);
|
|
67
|
-
return {
|
|
68
|
-
version: 1, hash: hash(message.content), timestamp: message.timestamp ?? -1,
|
|
69
|
-
source: matches.length === 1 ? matches[0]!.source : "unknown"
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Read raw active-branch entries, not projected model context: compaction and
|
|
75
|
-
* worker/assistant summaries must never manufacture human authorization.
|
|
76
|
-
* A marker is bound to the next user message by its full content hash/timestamp.
|
|
77
|
-
*/
|
|
78
|
-
export function userIntentFromBranch(branch: readonly IntentEntry[]): UserIntent {
|
|
79
|
-
const messages: IntentMessage[] = [];
|
|
80
|
-
const issues = new Set<string>();
|
|
81
|
-
const edited = new Set(branch.filter((entry) => entry.type === "context_edit").map((entry) => entry.targetId));
|
|
82
|
-
let marker: Provenance | undefined;
|
|
83
|
-
let currentRequestId: string | undefined;
|
|
84
|
-
let currentVerified = false;
|
|
85
|
-
let complete = true;
|
|
86
|
-
for (const entry of branch) {
|
|
87
|
-
if (entry.type === "custom" && entry.customType === INTENT_PROVENANCE) {
|
|
88
|
-
marker = entry.data as Provenance | undefined;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (entry.type !== "message" || entry.message?.role !== "user") continue;
|
|
92
|
-
const message = entry.message;
|
|
93
|
-
currentRequestId = entry.id;
|
|
94
|
-
currentVerified = Boolean(marker?.version === 1 && marker.timestamp === message.timestamp &&
|
|
95
|
-
marker.hash === hash(message.content) && (marker.source === "interactive" || marker.source === "rpc") && !edited.has(entry.id));
|
|
96
|
-
if (currentVerified) {
|
|
97
|
-
const text = textOf(message.content);
|
|
98
|
-
const redacted = redactIntentSecrets(text);
|
|
99
|
-
if (!text.trim() || redacted !== text || text.length > MAX_MESSAGE_CHARS ||
|
|
100
|
-
Array.isArray(message.content) && message.content.some((part) => part?.type !== "text")) {
|
|
101
|
-
complete = false;
|
|
102
|
-
issues.add("user-content-incomplete-or-redacted");
|
|
103
|
-
}
|
|
104
|
-
messages.push({ id: entry.id, source: marker!.source as "interactive" | "rpc", text: redacted.slice(0, MAX_MESSAGE_CHARS) });
|
|
105
|
-
} else {
|
|
106
|
-
issues.add("unverified-user-messages-excluded");
|
|
107
|
-
// Changed historical user constraints must not silently disappear.
|
|
108
|
-
if (edited.has(entry.id)) complete = false;
|
|
109
|
-
}
|
|
110
|
-
marker = undefined;
|
|
111
|
-
}
|
|
112
|
-
let total = 0;
|
|
113
|
-
const bounded: IntentMessage[] = [];
|
|
114
|
-
for (const message of messages.reverse()) {
|
|
115
|
-
if (bounded.length >= MAX_MESSAGES || total + message.text.length > MAX_CONTEXT_CHARS) {
|
|
116
|
-
complete = false;
|
|
117
|
-
issues.add("user-history-limit");
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
bounded.unshift(message);
|
|
121
|
-
total += message.text.length;
|
|
122
|
-
}
|
|
123
|
-
if (!currentVerified) {
|
|
124
|
-
complete = false;
|
|
125
|
-
issues.add("current-request-unverified");
|
|
126
|
-
}
|
|
127
|
-
return { currentRequestId, messages: bounded, complete, issues: [...issues] };
|
|
128
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Auto review categories. These are policy, not shell-parser guarantees. */
|
|
2
|
-
export const AUTO_REVIEW_POLICY = {
|
|
3
|
-
"ordinary-development": "Allow development work necessary for the current user request, including use of an already authenticated CLI without extracting credentials.",
|
|
4
|
-
"external-filesystem": "Require user intent covering the external target and operation. Project access does not authorize unrelated host files.",
|
|
5
|
-
"destructive-change": "Require specific authorization for the destructive operation, target, and scope. Never infer deletion, history rewriting, or irreversible changes from a general fix request.",
|
|
6
|
-
"credential-access": "Deny credential discovery, secret enumeration, extraction, or exposure, even if requested. Normal authenticated CLI use is not credential exploration.",
|
|
7
|
-
"data-egress": "Require authorization covering the destination, payload, and purpose. A trusted destination alone does not authorize uploading data.",
|
|
8
|
-
"shared-system": "Require explicit authorization for the exact shared/production target and operation. Local development intent does not authorize deployment, publishing, or remote infrastructure changes.",
|
|
9
|
-
"privilege-change": "Deny unattended privilege escalation or weakening security controls, even if requested.",
|
|
10
|
-
"safeguard-bypass": "Require explicit authorization to bypass the specific safeguard. Never infer --no-verify, disabled checks, or equivalent bypasses from 'fix it'.",
|
|
11
|
-
"opaque-execution": "Deny when the effects of scripts, downloaded code, wrappers, or command chains cannot be established from the supplied action. A test/build command name is not proof of safe effects.",
|
|
12
|
-
"ambiguous": "Deny when authorization, target, consequences, or the relationship to the current task is unclear."
|
|
13
|
-
} as const;
|
|
14
|
-
export type ReviewCategory = keyof typeof AUTO_REVIEW_POLICY;
|
|
15
|
-
export const NEVER_AUTO_ALLOW = new Set<ReviewCategory>(["credential-access", "privilege-change", "opaque-execution", "ambiguous"]);
|
package/lib/task-broker.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, open, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export const BROKER_LIMIT = 1024 * 1024;
|
|
6
|
-
export type BrokerAnswer = { id: string; outcome: "allowed" | "denied" | "cancelled" | "expired" | "unavailable"; reason?: string };
|
|
7
|
-
export type BrokerRequest = { id: string; token: string; tool: string; input: unknown; expiresAt: number };
|
|
8
|
-
export function validBrokerRequest(value: unknown, filename: string, token: string): value is BrokerRequest {
|
|
9
|
-
const request = value as BrokerRequest | undefined;
|
|
10
|
-
return Boolean(request && /^[a-f0-9-]{36}$/.test(request.id) && filename === `${request.id}.request.json` &&
|
|
11
|
-
request.token === token && typeof request.tool === "string" && request.tool.length < 100 &&
|
|
12
|
-
request.input && typeof request.input === "object" && !Array.isArray(request.input) &&
|
|
13
|
-
Number.isFinite(request.expiresAt) && request.expiresAt <= Date.now() + 5 * 60_000 + 1000);
|
|
14
|
-
}
|
|
15
|
-
export async function readBrokerJson(path: string): Promise<unknown> {
|
|
16
|
-
const info = await lstat(path);
|
|
17
|
-
if (!info.isFile() || info.size > BROKER_LIMIT) throw new Error("Invalid broker file.");
|
|
18
|
-
const file = await open(path, "r");
|
|
19
|
-
try {
|
|
20
|
-
const buffer = Buffer.alloc(BROKER_LIMIT + 1);
|
|
21
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
22
|
-
if (bytesRead > BROKER_LIMIT) throw new Error("Oversized broker file.");
|
|
23
|
-
return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
24
|
-
} finally { await file.close(); }
|
|
25
|
-
}
|
|
26
|
-
export async function publishBrokerJson(path: string, value: unknown): Promise<void> {
|
|
27
|
-
await writeFile(`${path}.tmp`, JSON.stringify(value), { mode: 0o600, flag: "wx" });
|
|
28
|
-
await rename(`${path}.tmp`, path);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function requestWorkerPermission(
|
|
32
|
-
broker: string, token: string, tool: string, input: unknown, signal?: AbortSignal,
|
|
33
|
-
options: { timeoutMs?: number; pollMs?: number; heartbeatMs?: number } = {}
|
|
34
|
-
): Promise<BrokerAnswer> {
|
|
35
|
-
const id = randomUUID();
|
|
36
|
-
const request = join(broker, `${id}.request.json`);
|
|
37
|
-
const response = join(broker, `${id}.response.json`);
|
|
38
|
-
const expiresAt = Date.now() + (options.timeoutMs ?? 5 * 60_000);
|
|
39
|
-
try {
|
|
40
|
-
if (signal?.aborted) return { id, outcome: "cancelled" };
|
|
41
|
-
const message = { id, token, tool, input, expiresAt };
|
|
42
|
-
if (Buffer.byteLength(JSON.stringify(message)) > BROKER_LIMIT) return { id, outcome: "denied", reason: "Worker action exceeds the broker size limit." };
|
|
43
|
-
await publishBrokerJson(request, message);
|
|
44
|
-
while (Date.now() < expiresAt) {
|
|
45
|
-
if (signal?.aborted) return { id, outcome: "cancelled" };
|
|
46
|
-
try {
|
|
47
|
-
const heartbeat = await stat(join(broker, "heartbeat"));
|
|
48
|
-
if (Date.now() - heartbeat.mtimeMs > (options.heartbeatMs ?? 10000)) return { id, outcome: "unavailable" };
|
|
49
|
-
} catch { return { id, outcome: "unavailable" }; }
|
|
50
|
-
try {
|
|
51
|
-
const answer = await readBrokerJson(response) as BrokerAnswer;
|
|
52
|
-
if (answer.id !== id || !["allowed", "denied", "cancelled", "expired"].includes(answer.outcome))
|
|
53
|
-
return { id, outcome: "denied", reason: "Invalid broker response." };
|
|
54
|
-
return signal?.aborted ? { id, outcome: "cancelled" } : answer;
|
|
55
|
-
} catch (error) {
|
|
56
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return { id, outcome: "denied", reason: "Invalid broker response." };
|
|
57
|
-
}
|
|
58
|
-
await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? 100));
|
|
59
|
-
}
|
|
60
|
-
return { id, outcome: "expired" };
|
|
61
|
-
} catch { return { id, outcome: "unavailable" }; }
|
|
62
|
-
finally {
|
|
63
|
-
await Promise.all([request, `${request}.tmp`, response].map((path) => rm(path, { force: true }).catch(() => undefined)));
|
|
64
|
-
}
|
|
65
|
-
}
|
package/lib/task-checkout.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
-
import { isProjectPath } from "./path-policy.ts";
|
|
4
|
-
import { taskGit, taskGitBytes } from "./task-git.ts";
|
|
5
|
-
|
|
6
|
-
/** Materialize raw Git blobs after worktree add --no-checkout. This intentionally
|
|
7
|
-
* skips repository hooks and smudge/process filters (including Git LFS hydration).
|
|
8
|
-
*/
|
|
9
|
-
export async function checkoutTaskFiles(root: string, base: string): Promise<void> {
|
|
10
|
-
const tree = (await taskGit(["ls-tree", "-rz", "--full-tree", base], root)).split("\0").filter(Boolean);
|
|
11
|
-
if (tree.length > 10000) throw new Error("Task checkout exceeds the file budget.");
|
|
12
|
-
let total = 0;
|
|
13
|
-
for (const entry of tree) {
|
|
14
|
-
const tab = entry.indexOf("\t");
|
|
15
|
-
const [mode, type, oid] = entry.slice(0, tab).split(" ");
|
|
16
|
-
const path = entry.slice(tab + 1);
|
|
17
|
-
if (tab < 0 || type !== "blob" || !["100644", "100755", "120000"].includes(mode) || isAbsolute(path) ||
|
|
18
|
-
path.includes("\\") || path.includes("\ufffd") || path.split("/").some((part) => !part || part === ".." || part.toLowerCase() === ".git"))
|
|
19
|
-
throw new Error("Task checkout contains unsupported submodules or unsafe paths.");
|
|
20
|
-
const target = join(root, path);
|
|
21
|
-
let ancestor = dirname(target);
|
|
22
|
-
while (true) {
|
|
23
|
-
try {
|
|
24
|
-
if (!isProjectPath(await realpath(ancestor), root)) throw new Error("Task checkout escapes its worktree.");
|
|
25
|
-
break;
|
|
26
|
-
} catch (error) {
|
|
27
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(ancestor) === ancestor) throw error;
|
|
28
|
-
ancestor = dirname(ancestor);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
await mkdir(dirname(target), { recursive: true });
|
|
32
|
-
const bytes = await taskGitBytes(["cat-file", "blob", oid], root, { maxBytes: 4 * 1024 * 1024 });
|
|
33
|
-
total += bytes.length;
|
|
34
|
-
if (total > 128 * 1024 * 1024) throw new Error("Task checkout exceeds its byte budget.");
|
|
35
|
-
if (mode === "120000") await symlink(bytes.toString("utf8"), target);
|
|
36
|
-
else { await writeFile(target, bytes, { flag: "wx" }); if (mode === "100755") await chmod(target, 0o755); }
|
|
37
|
-
}
|
|
38
|
-
await taskGit(["read-tree", base], root);
|
|
39
|
-
}
|
package/lib/task-git.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { devNull } from "node:os";
|
|
3
|
-
|
|
4
|
-
/** Internal Git operations never run hooks, external diff/textconv, or inherited
|
|
5
|
-
* GIT_* overrides. Snapshot code hashes bytes directly, bypassing clean filters.
|
|
6
|
-
*/
|
|
7
|
-
export function taskGitBytes(args: string[], cwd: string, options: {
|
|
8
|
-
env?: Record<string, string>; input?: Buffer | string; allowedCodes?: number[]; maxBytes?: number;
|
|
9
|
-
} = {}): Promise<Buffer> {
|
|
10
|
-
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
11
|
-
Object.assign(env, { GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" }, options.env);
|
|
12
|
-
return new Promise((resolve, reject) => {
|
|
13
|
-
const child = spawn("git", ["--no-pager", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${devNull}`,
|
|
14
|
-
"-c", "core.untrackedCache=false", ...args], { cwd, env, shell: false, stdio: ["pipe", "pipe", "pipe"] });
|
|
15
|
-
const chunks: Buffer[] = [];
|
|
16
|
-
let stderr = "";
|
|
17
|
-
let bytes = 0;
|
|
18
|
-
let overflow = false;
|
|
19
|
-
child.stdout.on("data", (chunk: Buffer) => {
|
|
20
|
-
bytes += chunk.length;
|
|
21
|
-
if (bytes > (options.maxBytes ?? 8 * 1024 * 1024)) { overflow = true; child.kill("SIGTERM"); }
|
|
22
|
-
else chunks.push(chunk);
|
|
23
|
-
});
|
|
24
|
-
child.stderr.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-4000); });
|
|
25
|
-
child.on("error", reject);
|
|
26
|
-
child.stdin.on("error", () => { /* Process termination may close stdin early. */ });
|
|
27
|
-
child.stdin.end(options.input);
|
|
28
|
-
child.on("close", (code) => {
|
|
29
|
-
if (overflow) reject(new Error("Git output exceeded the task inspection limit."));
|
|
30
|
-
else if ((options.allowedCodes ?? [0]).includes(code ?? -1)) resolve(Buffer.concat(chunks));
|
|
31
|
-
else reject(new Error(stderr.trim() || `Git exited ${code}`));
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function taskGit(args: string[], cwd: string, options: Parameters<typeof taskGitBytes>[2] = {}): Promise<string> {
|
|
37
|
-
return (await taskGitBytes(args, cwd, options)).toString("utf8");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function disabledTaskFilters(cwd: string): Promise<string[]> {
|
|
41
|
-
const keys = await taskGit(["config", "--null", "--name-only", "--get-regexp", "^filter\\."], cwd, { allowedCodes: [0, 1] });
|
|
42
|
-
const names = new Set(keys.split("\0").map((key) => key.match(/^filter\.(.+)\.(clean|smudge|process|required)$/s)?.[1]).filter(Boolean));
|
|
43
|
-
return [...names].flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.smudge=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`]);
|
|
44
|
-
}
|
package/lib/task-patch.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants } from "node:fs";
|
|
3
|
-
import { lstat, mkdir, mkdtemp, open, readlink, realpath, rm } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
6
|
-
import { evaluatePathPolicy, isProjectPath } from "./path-policy.ts";
|
|
7
|
-
import { taskGit } from "./task-git.ts";
|
|
8
|
-
|
|
9
|
-
export type TaskPatch = { patch: string; digest: string; changes: { path: string; warnings: string[] }[] };
|
|
10
|
-
function validPath(path: string): boolean {
|
|
11
|
-
return Boolean(path) && !isAbsolute(path) && !path.includes("\\") && !path.includes("\ufffd") &&
|
|
12
|
-
!path.split("/").some((part) => part === ".." || part.toLowerCase() === ".git");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function boundedFile(path: string): Promise<Buffer> {
|
|
16
|
-
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
17
|
-
try {
|
|
18
|
-
const before = await file.stat();
|
|
19
|
-
if (!before.isFile() || before.size > 4 * 1024 * 1024) throw new Error("Unsupported or oversized task file.");
|
|
20
|
-
const buffer = Buffer.alloc(before.size + 1);
|
|
21
|
-
let total = 0;
|
|
22
|
-
while (total < buffer.length) {
|
|
23
|
-
const { bytesRead } = await file.read(buffer, total, buffer.length - total, total);
|
|
24
|
-
if (!bytesRead) break;
|
|
25
|
-
total += bytesRead;
|
|
26
|
-
}
|
|
27
|
-
const after = await file.stat();
|
|
28
|
-
if (total !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs)
|
|
29
|
-
throw new Error("Task file changed during inspection; retry after writes have stopped.");
|
|
30
|
-
return buffer.subarray(0, total);
|
|
31
|
-
} finally { await file.close(); }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** A raw-byte snapshot in temporary index/object storage. Never stage into the
|
|
35
|
-
* real index or write Git objects into the repository, and never run clean filters.
|
|
36
|
-
* Limits are fail-closed; unsupported submodules/special files are not omitted.
|
|
37
|
-
*/
|
|
38
|
-
export async function inspectTaskPatch(worktree: string, repository: string, base: string): Promise<TaskPatch> {
|
|
39
|
-
if (!/^[a-f0-9]{40,64}$/i.test(base)) throw new Error("Invalid task base revision.");
|
|
40
|
-
const root = await realpath(worktree);
|
|
41
|
-
const tree = await taskGit(["ls-tree", "-rz", "--full-tree", base], root);
|
|
42
|
-
const names = new Set<string>();
|
|
43
|
-
for (const entry of tree.split("\0").filter(Boolean)) {
|
|
44
|
-
const tab = entry.indexOf("\t");
|
|
45
|
-
if (tab < 0 || entry.startsWith("160000 ")) throw new Error("Submodules or malformed tree entries require manual review.");
|
|
46
|
-
names.add(entry.slice(tab + 1));
|
|
47
|
-
}
|
|
48
|
-
for (const args of [["ls-files", "--cached", "-z"], ["ls-files", "--others", "--exclude-standard", "-z"]]) {
|
|
49
|
-
for (const name of (await taskGit(args, root)).split("\0").filter(Boolean)) names.add(name);
|
|
50
|
-
}
|
|
51
|
-
if (names.size > 10000 || [...names].some((name) => !validPath(name))) throw new Error("Task snapshot has too many files or unsafe paths.");
|
|
52
|
-
const temp = await mkdtemp(join(tmpdir(), "piexis-inspect-"));
|
|
53
|
-
try {
|
|
54
|
-
const objects = join(temp, "objects");
|
|
55
|
-
await mkdir(objects);
|
|
56
|
-
const originalObjects = await realpath(resolve(root, (await taskGit(["rev-parse", "--git-path", "objects"], root)).trim()));
|
|
57
|
-
const env = { GIT_INDEX_FILE: join(temp, "index"), GIT_OBJECT_DIRECTORY: objects, GIT_ALTERNATE_OBJECT_DIRECTORIES: originalObjects };
|
|
58
|
-
await taskGit(["read-tree", "--empty"], root, { env });
|
|
59
|
-
let total = 0;
|
|
60
|
-
const entries: string[] = [];
|
|
61
|
-
for (const path of [...names].sort()) {
|
|
62
|
-
const target = join(root, path);
|
|
63
|
-
let stat;
|
|
64
|
-
try { stat = await lstat(target); }
|
|
65
|
-
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
66
|
-
if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error(`Unsupported task file: ${path}`);
|
|
67
|
-
if (stat.size > 4 * 1024 * 1024) throw new Error(`Task file exceeds the 4 MiB snapshot limit: ${path}`);
|
|
68
|
-
// Do not follow a symlinked ancestor or a symlink file outside the worktree.
|
|
69
|
-
if (!isProjectPath(await realpath(join(target, "..")), root)) throw new Error("Task snapshot traverses an external directory.");
|
|
70
|
-
const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(target)) : await boundedFile(target);
|
|
71
|
-
total += bytes.length;
|
|
72
|
-
if (bytes.length > 4 * 1024 * 1024 || total > 128 * 1024 * 1024) throw new Error("Task snapshot exceeds its byte budget.");
|
|
73
|
-
const oid = (await taskGit(["hash-object", "-w", "--no-filters", "--stdin"], root, { env, input: bytes })).trim();
|
|
74
|
-
const mode = stat.isSymbolicLink() ? "120000" : stat.mode & 0o111 ? "100755" : "100644";
|
|
75
|
-
entries.push(`${mode} ${oid}\t${path}\0`);
|
|
76
|
-
}
|
|
77
|
-
await taskGit(["update-index", "-z", "--index-info"], root, { env, input: entries.join("") });
|
|
78
|
-
const args = ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-renames", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
79
|
-
const changed = (await taskGit([...args, "--name-only", "-z", base, "--"], root, { env })).split("\0").filter(Boolean);
|
|
80
|
-
const changes: TaskPatch["changes"] = [];
|
|
81
|
-
for (const path of changed) {
|
|
82
|
-
if (!validPath(path)) throw new Error("Unsafe changed path in task snapshot.");
|
|
83
|
-
const warnings: string[] = [];
|
|
84
|
-
for (const boundary of [root, repository]) {
|
|
85
|
-
const policy = await evaluatePathPolicy("write", { path }, boundary, boundary);
|
|
86
|
-
if (policy?.decision !== "allow") warnings.push(policy?.reason ?? "unknown-path");
|
|
87
|
-
}
|
|
88
|
-
const type = await lstat(join(root, path)).catch(() => undefined);
|
|
89
|
-
if (type?.isSymbolicLink()) warnings.push("symlink-change");
|
|
90
|
-
if (/(^|\/)(AGENTS\.md|CLAUDE\.md|package\.json|Makefile|\.gitattributes|\.gitmodules)$|^\.github\/workflows\//i.test(path))
|
|
91
|
-
warnings.push("execution-or-instruction-configuration");
|
|
92
|
-
changes.push({ path, warnings: [...new Set(warnings)] });
|
|
93
|
-
}
|
|
94
|
-
const patch = await taskGit([...args, "--binary", "--full-index", base, "--"], root, { env });
|
|
95
|
-
return { patch, digest: createHash("sha256").update(patch).digest("hex"), changes };
|
|
96
|
-
} finally { await rm(temp, { recursive: true, force: true }); }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function taskPatchPreview(snapshot: TaskPatch): string {
|
|
100
|
-
if (!snapshot.changes.length) return "No file changes.";
|
|
101
|
-
const summary = snapshot.changes.map((change) => `${JSON.stringify(change.path)}${change.warnings.length ? ` [${change.warnings.join(", ")}]` : ""}`).join("\n");
|
|
102
|
-
const text = snapshot.changes.some((change) => change.warnings.length)
|
|
103
|
-
? `Patch contents withheld because sensitive, symlink, or configuration changes require explicit review. Inspect these files before applying.\n\n${summary}`
|
|
104
|
-
: `${summary}\n\n${snapshot.patch}`;
|
|
105
|
-
return text.length > 50000 ? `${text.slice(0, 49880)}\n[Patch display truncated; application still requires confirmation of the full snapshot.]` : text;
|
|
106
|
-
}
|
package/lib/task-permissions.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { isAbsolute, resolve } from "node:path";
|
|
3
|
-
import { isProjectPath } from "./path-policy.ts";
|
|
4
|
-
import type { DenialLimits } from "./permission-denials.ts";
|
|
5
|
-
|
|
6
|
-
export const TASK_PERMISSION_SERVICE = "piexis:task-permission-service";
|
|
7
|
-
export const TASK_INVALIDATED = "piexis:task-permissions-invalidated";
|
|
8
|
-
export const TASK_TOOLS = new Set(["background_task", "task_status", "task_cancel", "task_diff", "task_apply", "task_clean"]);
|
|
9
|
-
export const MANAGED_TASK_TOOLS = new Set(["background_task", "task_apply", "task_clean"]);
|
|
10
|
-
export const WORKER_TOOLS = new Set(["read", "grep", "find", "glob", "ls", "edit", "write", "bash"]);
|
|
11
|
-
|
|
12
|
-
export type TaskScope = { paths: string[]; bash: boolean };
|
|
13
|
-
export type TaskSpec = TaskScope & { task: string; model?: string };
|
|
14
|
-
export type TaskPermission = { allow: boolean; reason?: string };
|
|
15
|
-
export type TaskLease = {
|
|
16
|
-
signal: AbortSignal;
|
|
17
|
-
limits: DenialLimits;
|
|
18
|
-
valid(): boolean;
|
|
19
|
-
close(): void;
|
|
20
|
-
authorize(tool: string, input: unknown, worktree: string, signal?: AbortSignal): Promise<TaskPermission>;
|
|
21
|
-
};
|
|
22
|
-
export type TaskPermissionService = {
|
|
23
|
-
open(spec: TaskSpec, userCommand: boolean, signal?: AbortSignal): Promise<TaskLease>;
|
|
24
|
-
control(tool: "task_apply" | "task_clean", id: string): Promise<void>;
|
|
25
|
-
declined(tool: "task_apply" | "task_clean", id: string, reason?: string): string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/** Synchronous discovery on Pi's in-process event bus. Absence fails closed.
|
|
29
|
-
* This is a trusted-extension API, not authentication against malicious plugins.
|
|
30
|
-
*/
|
|
31
|
-
export function taskPermissionService(pi: ExtensionAPI): TaskPermissionService {
|
|
32
|
-
let service: TaskPermissionService | undefined;
|
|
33
|
-
pi.events.emit(TASK_PERMISSION_SERVICE, { provide(value: TaskPermissionService) { service = value; } });
|
|
34
|
-
if (!service) throw new Error("Background tasks require the active Piexis permission extension.");
|
|
35
|
-
return service;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function validateTaskScope(paths: unknown, bash: boolean): TaskScope {
|
|
39
|
-
const values = paths === undefined ? ["."] : paths;
|
|
40
|
-
if (!Array.isArray(values) || !values.length || values.length > 32 || values.some((path) =>
|
|
41
|
-
typeof path !== "string" || !path || path.length > 500 || path.includes("\0") || path.includes("\\") ||
|
|
42
|
-
isAbsolute(path) || path.split("/").some((part) => part === ".." || part.toLowerCase() === ".git")))
|
|
43
|
-
throw new Error("Task paths must be a nonempty list of relative files/directories without traversal or .git.");
|
|
44
|
-
const normalized = [...new Set(values.map((path: string) => path.replace(/^\.\//, "").replace(/\/$/, "") || "."))];
|
|
45
|
-
if (bash && !normalized.includes(".")) throw new Error("Worker Bash requires whole-worktree scope; it cannot enforce a narrower path list.");
|
|
46
|
-
return { paths: normalized, bash };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function inTaskScope(path: string, root: string, scope: TaskScope): boolean {
|
|
50
|
-
return isProjectPath(path, root) && scope.paths.some((entry) => isProjectPath(path, resolve(root, entry)));
|
|
51
|
-
}
|
package/lib/task-report.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { redactIntentSecrets } from "./permission-intent.ts";
|
|
2
|
-
|
|
3
|
-
export type ObservedCommand = { id: string; command: string; outcome: "completed" | "failed" };
|
|
4
|
-
/** Only runtime tool execution events count as verification observations. Worker
|
|
5
|
-
* prose remains untrusted and cannot assert that a command actually ran.
|
|
6
|
-
*/
|
|
7
|
-
export class TaskReport {
|
|
8
|
-
private buffer = "";
|
|
9
|
-
private discarding = false;
|
|
10
|
-
private pending = new Set<string>();
|
|
11
|
-
commands: ObservedCommand[] = [];
|
|
12
|
-
output = "(The subagent produced no final text.)";
|
|
13
|
-
failed = false;
|
|
14
|
-
incomplete = false;
|
|
15
|
-
push(chunk: string): void {
|
|
16
|
-
for (const char of chunk) {
|
|
17
|
-
if (char === "\n") {
|
|
18
|
-
if (!this.discarding && this.buffer) this.event(this.buffer);
|
|
19
|
-
this.buffer = "";
|
|
20
|
-
this.discarding = false;
|
|
21
|
-
} else if (!this.discarding) {
|
|
22
|
-
if (this.buffer.length >= 256000) { this.buffer = ""; this.discarding = true; this.incomplete = true; }
|
|
23
|
-
else this.buffer += char;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
finish(): void {
|
|
28
|
-
if (this.buffer && !this.discarding) this.event(this.buffer);
|
|
29
|
-
this.buffer = "";
|
|
30
|
-
if (this.pending.size) this.incomplete = true;
|
|
31
|
-
}
|
|
32
|
-
private event(line: string): void {
|
|
33
|
-
let event;
|
|
34
|
-
try { event = JSON.parse(line); } catch { this.incomplete = true; return; }
|
|
35
|
-
if (!event || typeof event !== "object" || Array.isArray(event)) { this.incomplete = true; return; }
|
|
36
|
-
if (["bash", "worker_bash"].includes(event.toolName) && typeof event.toolCallId === "string") {
|
|
37
|
-
if (event.type === "tool_execution_start") {
|
|
38
|
-
if (this.pending.size < 1000) this.pending.add(event.toolCallId); else this.incomplete = true;
|
|
39
|
-
}
|
|
40
|
-
if (event.type === "tool_execution_end") this.pending.delete(event.toolCallId);
|
|
41
|
-
}
|
|
42
|
-
if (event.type === "entry_appended" && event.entry?.customType === "piexis-worker-execution") {
|
|
43
|
-
const data = event.entry.data;
|
|
44
|
-
if (!data || typeof data.id !== "string" || typeof data.command !== "string" || typeof data.isError !== "boolean" ||
|
|
45
|
-
this.commands.length >= 100) { this.incomplete = true; return; }
|
|
46
|
-
if (data.truncated === true) this.incomplete = true;
|
|
47
|
-
if (!this.commands.some((command) => command.id === data.id)) this.commands.push({
|
|
48
|
-
id: data.id, command: redactIntentSecrets(data.command).slice(0, 2000), outcome: data.isError ? "failed" : "completed"
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
52
|
-
const text = Array.isArray(event.message.content)
|
|
53
|
-
? event.message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n") : "";
|
|
54
|
-
if (text) this.output = redactIntentSecrets(text).slice(0, 12000);
|
|
55
|
-
if (["error", "aborted"].includes(event.message.stopReason)) this.failed = true;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function verificationSummary(commands: ObservedCommand[] = [], incomplete = false): string {
|
|
61
|
-
return `${commands.length ? commands.map((command) => `${command.outcome}: ${JSON.stringify(command.command)}`).join("\n") : "No Bash verification execution was observed."}${incomplete ? "\nExecution reporting is incomplete; do not infer successful verification." : ""}`;
|
|
62
|
-
}
|