mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-11 18:38:44 +02:00
refactor(worker): converge shared core with shannon-oss (#388)
* fix(worker): port keygraph shared-core correctness fixes * refactor(worker): adopt collectors/ and ai/pi/ layout; add task budget cap and cancellation * refactor(worker): drop inconsistent Collector "Server" suffix * refactor(worker): drop unused providerConfig/apiKey seams, resolve credentials from env only * refactor(worker): port oss code_path pattern expansion + external_directory allow * fix(worker): preserve dotfile paths in code_path avoid patterns (.env no longer stripped to env) * feat(worker): render Unprocessed Vulnerabilities section in exploit deliverable (align with oss) * feat(worker): request set_blind_spots for all vuln classes (align auth/ssrf with production prompts) * refactor(worker): adopt unified permissionSystem* naming and helper layout * refactor(worker): inline blind_spots into vuln deliverable section array * chore(worker): drop unused zod dependency (tree is typebox-native) * fix(worker): normalize base32 TOTP secret to accept padding and whitespace * refactor(worker): adopt shared toolResult helper and flatSchema naming in collectors * refactor(worker): use undefined over null in queue-schema builders * docs(worker): converge renderer/collector doc comments to current pi terminology * refactor(worker): adopt schema.ts cleanInput/stringEnum helpers in collectors * feat(worker): converge exploit-collector/renderer with vendored; capture and render overview for blocked findings * refactor(worker): converge session-tools/pipeline/exploitation-checker with vendored * refactor(worker): converge task-tool usage reporting with vendored onUsage callback * refactor(worker): converge structured output onto a submitTool executor channel * docs(worker): expand exploit-renderer docstring to match shannon-oss * docs(worker): adopt richer vuln-renderer docstring from shannon-oss * docs(worker): neutralize billing-detection wording for shannon-oss parity * fix(worker): verify checkpoint hash in the deliverables clone being reset * fix(worker): fail fast on malformed exploitation queue JSON * fix(worker): honor retryable flag when classifying exploitation-queue check failures * fix(worker): fail fast on corrupted session.json in run-scope validation * feat(worker): propagate Temporal cancellation signal into agent and auth pi sessions * fix(worker): mark exploit agent complete when exploitation is skipped so resume skips it * prompts: drop scan description from executive report prompt * refactor(worker): add createGenericSubmitTool for raw JSON-schema submit tools * refactor(worker): gate playwright-cli skill to browser agents via skillsOverride (adopt shannon-oss mechanism) * docs(worker): correct formatLogTime comment to UTC to match toISOString * refactor(worker): converge queue-schemas with shannon-oss (guarded count, decl order) * refactor(worker): converge task-tool with shannon-oss (byte-identical; modelRegistry optional) * fix(worker): use replaceLiteral for all prompt value insertions to prevent $-mangling * fix(worker): classify agent execution failures by error type instead of hardcoding validation * fix(worker): cap auth-failure detail at 250 chars to match shannon-oss * style(worker): apply biome formatting * refactor(worker): remove per-session task delegation cap from task tool
This commit is contained in:
1 parent
b845936aa5
commit
d0b0ec3378
47 files changed
+2007
-1334
No files matched your search
@@ -16,18 +16,16 @@
|
||||
* ANTHROPIC_LARGE_MODEL, which works across all providers (Anthropic, Bedrock,
|
||||
* custom base URL).
|
||||
*
|
||||
* The active provider is chosen from an injected `providerConfig` (the Pro consumer)
|
||||
* or, in OSS, from the env-var contract the CLI forwards (`CLAUDE_CODE_USE_BEDROCK`,
|
||||
* `ANTHROPIC_BASE_URL`+`ANTHROPIC_AUTH_TOKEN`, else direct Anthropic). Resolution
|
||||
* returns a pi `Model` via `ModelRegistry.find`, the `thinkingLevel`, and an
|
||||
* `AuthStorage` primed with the right credential. Bedrock authenticates from the
|
||||
* AWS_ env vars via pi-ai.
|
||||
* The active provider is chosen from the env-var contract the CLI forwards
|
||||
* (`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_BASE_URL`+`ANTHROPIC_AUTH_TOKEN`, else
|
||||
* direct Anthropic). Resolution returns a pi `Model` via `ModelRegistry.find`, the
|
||||
* `thinkingLevel`, and an `AuthStorage` primed with the right credential. Bedrock
|
||||
* authenticates from the AWS_ env vars via pi-ai.
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
||||
import type { Api, Model } from '@earendil-works/pi-ai';
|
||||
import { AuthStorage, type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
||||
import type { ProviderConfig } from '../types/config.js';
|
||||
|
||||
export type ModelTier = 'small' | 'medium' | 'large';
|
||||
|
||||
@@ -47,34 +45,20 @@ export interface EffectiveProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the active provider + auth.
|
||||
*
|
||||
* An explicit `providerConfig` (injected by the Pro consumer) wins; otherwise we
|
||||
* fall back to the OSS env-var contract the CLI forwards: `CLAUDE_CODE_USE_BEDROCK`
|
||||
* → Bedrock; `ANTHROPIC_BASE_URL`+`ANTHROPIC_AUTH_TOKEN` → custom base URL; else
|
||||
* direct Anthropic (`ANTHROPIC_API_KEY`, or `CLAUDE_CODE_OAUTH_TOKEN`). Bedrock
|
||||
* authenticates from the AWS_ env vars via pi-ai, so it needs no anthropic token.
|
||||
* Determine the active provider + auth from the env-var contract the CLI forwards:
|
||||
* `CLAUDE_CODE_USE_BEDROCK` → Bedrock; `ANTHROPIC_BASE_URL`+`ANTHROPIC_AUTH_TOKEN`
|
||||
* → custom base URL; else direct Anthropic (`ANTHROPIC_API_KEY`, or
|
||||
* `CLAUDE_CODE_OAUTH_TOKEN`). Bedrock authenticates from the AWS_ env vars via
|
||||
* pi-ai, so it needs no anthropic token.
|
||||
*/
|
||||
export function resolveEffectiveProvider(apiKey?: string, providerConfig?: ProviderConfig): EffectiveProvider {
|
||||
const anthropicKey = apiKey ?? providerConfig?.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
||||
const type = providerConfig?.providerType;
|
||||
|
||||
// Bedrock — explicit providerConfig or the env flag.
|
||||
if (type === 'bedrock' || (!type && process.env.CLAUDE_CODE_USE_BEDROCK === '1')) {
|
||||
export function resolveEffectiveProvider(): EffectiveProvider {
|
||||
// Bedrock — env flag.
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
|
||||
return { providerId: 'amazon-bedrock' };
|
||||
}
|
||||
|
||||
// Custom base URL — explicit providerConfig.
|
||||
if (type === 'custom_base_url') {
|
||||
const eff: EffectiveProvider = { providerId: 'anthropic' };
|
||||
if (providerConfig?.baseUrl) eff.baseUrl = providerConfig.baseUrl;
|
||||
const token = providerConfig?.authToken ?? anthropicKey;
|
||||
if (token) eff.anthropicToken = token;
|
||||
return eff;
|
||||
}
|
||||
|
||||
// Custom base URL — OSS env contract (no providerConfig).
|
||||
if (!type && process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
// Custom base URL — env contract.
|
||||
if (process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
return {
|
||||
providerId: 'anthropic',
|
||||
baseUrl: process.env.ANTHROPIC_BASE_URL,
|
||||
@@ -82,17 +66,15 @@ export function resolveEffectiveProvider(apiKey?: string, providerConfig?: Provi
|
||||
};
|
||||
}
|
||||
|
||||
// Direct Anthropic (API key, or — env only — OAuth token).
|
||||
// Direct Anthropic (API key, or OAuth token).
|
||||
const eff: EffectiveProvider = { providerId: 'anthropic' };
|
||||
const token = anthropicKey ?? (type ? undefined : process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
||||
const token = process.env.ANTHROPIC_API_KEY ?? process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
if (token) eff.anthropicToken = token;
|
||||
return eff;
|
||||
}
|
||||
|
||||
/** Resolve a model tier to a concrete model ID (env override → providerConfig → default). */
|
||||
export function resolveModelId(tier: ModelTier = 'medium', providerConfig?: ProviderConfig): string {
|
||||
const override = providerConfig?.modelOverrides?.[tier];
|
||||
if (override) return override;
|
||||
/** Resolve a model tier to a concrete model ID (env override → default). */
|
||||
export function resolveModelId(tier: ModelTier = 'medium'): string {
|
||||
switch (tier) {
|
||||
case 'small':
|
||||
return process.env.ANTHROPIC_SMALL_MODEL || DEFAULT_MODELS.small;
|
||||
@@ -137,11 +119,9 @@ export interface ModelSelection {
|
||||
export function resolveModelSelection(
|
||||
registryFactory: (authStorage: AuthStorage) => ModelRegistry,
|
||||
modelTier: ModelTier,
|
||||
apiKey?: string,
|
||||
providerConfig?: ProviderConfig,
|
||||
): ModelSelection {
|
||||
const eff = resolveEffectiveProvider(apiKey, providerConfig);
|
||||
const modelId = resolveModelId(modelTier, providerConfig);
|
||||
const eff = resolveEffectiveProvider();
|
||||
const modelId = resolveModelId(modelTier);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
if (eff.providerId === 'anthropic' && eff.anthropicToken) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* code_path "avoid" enforcement for the pi harness, delegated to the
|
||||
* @gotgenes/pi-permission-system extension.
|
||||
*
|
||||
* Each `code_path` avoid is translated into the extension's cross-cutting `path`
|
||||
* deny surface — the strongest gate, blocking file access (read/edit/write/grep/
|
||||
* find/ls) AND recognized bash file commands (cat/grep/sed/…) on any matching path,
|
||||
* across every tool and child `task` session, not overridable by a per-tool allow.
|
||||
*
|
||||
* `external_directory: allow` keeps the extension from gating the agent's legitimate
|
||||
* access outside the working directory once it is loaded (the pentest agent shells
|
||||
* out to tools/paths outside the mounted repo). When there are no avoids the config
|
||||
* is removed so the executor skips loading the extension entirely.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
||||
import type { DistributedConfig } from '../../types/config.js';
|
||||
|
||||
const PERMISSION_EXTENSION_ID = 'pi-permission-system';
|
||||
|
||||
/**
|
||||
* Translate one avoid value into the extension's flat-wildcard `path` patterns.
|
||||
*
|
||||
* The extension's `*` already spans path separators (no `**` globstar), and tool
|
||||
* paths are compared as absolute. A plain directory value is expanded to cover the
|
||||
* directory itself and everything under it, in both cwd-relative and prefixed
|
||||
* (absolute) positions. Glob values fold `**`→`*`; a `dir/*` contents glob also
|
||||
* denies the directory entry itself.
|
||||
*/
|
||||
export function toPathPatterns(value: string): string[] {
|
||||
// Strip only leading path prefixes ("/", "./", "../"); preserve a dotfile's dot
|
||||
// (so `.env` stays `.env`, not `env`).
|
||||
const base = value.replace(/^(?:\.{0,2}\/)+/, '').replace(/\/+$/, '');
|
||||
if (!base) return [];
|
||||
|
||||
if (base.includes('*') || base.includes('?')) {
|
||||
// The extension's `*` already spans path separators, so fold `**` to `*`.
|
||||
const flat = base.replace(/\*\*\//g, '*/').replace(/\*\*/g, '*');
|
||||
const tail = flat.replace(/^(?:\*\/)+/, '');
|
||||
const patterns = [flat, `*/${tail}`];
|
||||
// Depth-agnostic catch-all only for a bare-name tail (so `**/*.env` hits a
|
||||
// root-level `.env`); a structured tail would over-match sibling names.
|
||||
if (!tail.includes('/')) {
|
||||
patterns.push(tail.startsWith('*') ? tail : `*${tail}`);
|
||||
}
|
||||
// A `dir/*` contents glob should also deny the directory entry itself — the
|
||||
// contents patterns require a trailing segment and wouldn't match the folder.
|
||||
if (flat.endsWith('/*')) {
|
||||
const folder = flat.slice(0, -2);
|
||||
if (folder && !folder.includes('*')) {
|
||||
patterns.push(folder, `*/${folder}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(patterns)];
|
||||
}
|
||||
|
||||
return [base, `${base}/*`, `*/${base}`, `*/${base}/*`];
|
||||
}
|
||||
|
||||
interface PermissionSystemConfig {
|
||||
permission: {
|
||||
'*': 'allow';
|
||||
path: Record<string, 'allow' | 'deny'>;
|
||||
external_directory: 'allow';
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the extension config that denies every avoid pattern across all tools. */
|
||||
export function buildPermissionConfig(patterns: readonly string[]): PermissionSystemConfig {
|
||||
// Default allow first; deny entries are appended so they win (last match wins).
|
||||
const pathRules: Record<string, 'allow' | 'deny'> = { '*': 'allow' };
|
||||
for (const pattern of patterns) {
|
||||
for (const expanded of toPathPatterns(pattern)) {
|
||||
pathRules[expanded] = 'deny';
|
||||
}
|
||||
}
|
||||
return {
|
||||
permission: {
|
||||
'*': 'allow',
|
||||
path: pathRules,
|
||||
external_directory: 'allow',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Path to the extension's global config under the agent directory. */
|
||||
export function permissionSystemConfigPath(agentDir: string): string {
|
||||
return path.join(agentDir, 'extensions', PERMISSION_EXTENSION_ID, 'config.json');
|
||||
}
|
||||
|
||||
/** True when a pi-permission-system config has been written (avoid rules exist). */
|
||||
export function permissionSystemConfigExists(agentDir: string): boolean {
|
||||
return fs.existsSync(permissionSystemConfigPath(agentDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the distributed config's `code_path` avoids into the extension's global
|
||||
* config (`<agentDir>/extensions/pi-permission-system/config.json`). When there
|
||||
* are no avoids the config is removed so the executor skips loading the extension.
|
||||
*
|
||||
* Global (not project) config is used deliberately: it loads synchronously at
|
||||
* extension init without depending on a session_start/ctx, it keeps the config
|
||||
* out of the scanned repo, and it is idempotent across the agents of one run.
|
||||
*/
|
||||
export function syncPermissionSystemConfig(config: DistributedConfig | null): void {
|
||||
const configPath = permissionSystemConfigPath(getAgentDir());
|
||||
const avoidRules = (config?.avoid ?? []).filter((r) => r.type === 'code_path');
|
||||
|
||||
if (avoidRules.length === 0) {
|
||||
fs.rmSync(configPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-repo (fixed mount): patterns are the raw avoid values.
|
||||
const patterns = avoidRules.map((r) => r.value);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(buildPermissionConfig(patterns), null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the installed @gotgenes/pi-permission-system package directory,
|
||||
* suitable for `DefaultResourceLoader`'s `additionalExtensionPaths`. The loader
|
||||
* reads the package's `pi.extensions` manifest and loads the extension itself.
|
||||
*
|
||||
* The package's `.` export points at its service module, so we resolve that and
|
||||
* walk up to the package root. Throws if the package is not resolvable.
|
||||
*/
|
||||
export function permissionSystemPackageDir(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
const servicePath = require.resolve('@gotgenes/pi-permission-system');
|
||||
return path.resolve(path.dirname(servicePath), '..');
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
// Production agent execution on the pi harness, with git checkpoints and audit logging.
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import os from 'node:os';
|
||||
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
||||
import {
|
||||
type AgentSessionEvent,
|
||||
@@ -17,30 +17,34 @@ import {
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type Skill,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir, PLAYWRIGHT_SKILL_DIR } from '../paths.js';
|
||||
import { isRetryableError, PentestError } from '../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS } from '../session-manager.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { isSpendingCapBehavior, matchesBillingTextPattern } from '../utils/billing-detection.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { Timer } from '../utils/metrics.js';
|
||||
import { createAuditLogger } from './audit-logger.js';
|
||||
import { type ModelTier, resolveModelSelection } from './models.js';
|
||||
import type { AuditSession } from '../../audit/index.js';
|
||||
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir } from '../../paths.js';
|
||||
import { isRetryableError, PentestError } from '../../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS } from '../../session-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import { ErrorCode } from '../../types/errors.js';
|
||||
import { isSpendingCapBehavior, matchesBillingTextPattern } from '../../utils/billing-detection.js';
|
||||
import { isBrowserAgent } from '../../utils/browser-agents.js';
|
||||
import { formatTimestamp } from '../../utils/formatting.js';
|
||||
import { Timer } from '../../utils/metrics.js';
|
||||
import { createAuditLogger } from '../audit-logger.js';
|
||||
import { type ModelTier, resolveModelSelection } from '../models.js';
|
||||
import {
|
||||
detectExecutionContext,
|
||||
formatAssistantOutput,
|
||||
formatCompletionMessage,
|
||||
formatErrorOutput,
|
||||
formatToolCall,
|
||||
} from './output-formatters.js';
|
||||
import { createProgressManager } from './progress-manager.js';
|
||||
import { permissionConfigPath } from './settings-writer.js';
|
||||
import { createGlobTool, createTaskTool, createTodoWriteTool } from './tools.js';
|
||||
} from '../output-formatters.js';
|
||||
import { createProgressManager } from '../progress-manager.js';
|
||||
import type { CapturedSubmitTool } from '../submit-tool.js';
|
||||
import { permissionSystemConfigExists, permissionSystemPackageDir } from './permission-system.js';
|
||||
import { createGlobTool, createTodoWriteTool } from './session-tools.js';
|
||||
import { createTaskTool } from './task-tool.js';
|
||||
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
@@ -49,40 +53,53 @@ declare global {
|
||||
/** Built-in pi tools enabled for every agent (custom tool names are appended). */
|
||||
const BUILTIN_TOOLS = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'];
|
||||
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
let cachedExtensionDir: string | null | undefined;
|
||||
|
||||
/** Resolve the installed @gotgenes/pi-permission-system package dir, or null. */
|
||||
function permissionExtensionDir(): string | null {
|
||||
if (cachedExtensionDir !== undefined) return cachedExtensionDir;
|
||||
try {
|
||||
const entry = requireFromHere.resolve('@gotgenes/pi-permission-system');
|
||||
cachedExtensionDir = path.dirname(path.dirname(entry));
|
||||
} catch {
|
||||
cachedExtensionDir = null;
|
||||
}
|
||||
return cachedExtensionDir;
|
||||
/** Build the playwright-cli Skill object injected for browser-using agents. */
|
||||
function buildPlaywrightSkill(): Skill {
|
||||
const filePath =
|
||||
process.env.PLAYWRIGHT_CLI_SKILL_PATH ?? path.join(os.homedir(), '.claude/skills/playwright-cli/SKILL.md');
|
||||
const baseDir = path.dirname(filePath);
|
||||
return {
|
||||
name: 'playwright-cli',
|
||||
description:
|
||||
'Drive a real browser via the playwright-cli binary. Use for any task that navigates, clicks, ' +
|
||||
'fills forms, takes screenshots, or reads live pages.',
|
||||
filePath,
|
||||
baseDir,
|
||||
sourceInfo: { path: filePath, source: 'custom', scope: 'user', origin: 'top-level', baseDir },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildResourceLoader(cwd: string, logger: ActivityLogger): Promise<ResourceLoader> {
|
||||
async function buildResourceLoader(
|
||||
cwd: string,
|
||||
logger: ActivityLogger,
|
||||
agentName: string | null,
|
||||
): Promise<ResourceLoader> {
|
||||
// Always enforce bounded bash timeouts so an unbounded command cannot hang the agent.
|
||||
const additionalExtensionPaths: string[] = [BASH_TIMEOUT_EXTENSION_DIR];
|
||||
if (fs.existsSync(permissionConfigPath())) {
|
||||
const extDir = permissionExtensionDir();
|
||||
if (extDir) {
|
||||
additionalExtensionPaths.push(extDir);
|
||||
} else {
|
||||
if (permissionSystemConfigExists(getAgentDir())) {
|
||||
try {
|
||||
additionalExtensionPaths.push(permissionSystemPackageDir());
|
||||
} catch {
|
||||
logger.warn(
|
||||
'code_path deny config present but @gotgenes/pi-permission-system not resolvable — skipping enforcement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only browser-driving agents get the playwright-cli skill; the rest run with no skills.
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir: getAgentDir(),
|
||||
additionalSkillPaths: [PLAYWRIGHT_SKILL_DIR],
|
||||
...(additionalExtensionPaths.length > 0 && { additionalExtensionPaths }),
|
||||
...(isBrowserAgent(agentName)
|
||||
? {
|
||||
skillsOverride: (base) => ({
|
||||
skills: [buildPlaywrightSkill()],
|
||||
diagnostics: base.diagnostics,
|
||||
}),
|
||||
}
|
||||
: { noSkills: true }),
|
||||
});
|
||||
await loader.reload();
|
||||
return loader;
|
||||
@@ -202,18 +219,20 @@ export async function runPiPrompt(
|
||||
sourceDir: string,
|
||||
context: string = '',
|
||||
description: string = 'Agent analysis',
|
||||
_agentName: string | null = null,
|
||||
agentName: string | null = null,
|
||||
auditSession: AuditSession | null = null,
|
||||
logger: ActivityLogger,
|
||||
modelTier: ModelTier = 'medium',
|
||||
callerTools?: ToolDefinition[],
|
||||
apiKey?: string,
|
||||
deliverablesSubdir?: string,
|
||||
providerConfig?: import('../types/config.js').ProviderConfig,
|
||||
cancellationSignal?: AbortSignal,
|
||||
submitTool?: CapturedSubmitTool,
|
||||
): Promise<PiPromptResult> {
|
||||
// 1. Initialize timing and prompt
|
||||
// 1. Initialize timing and prompt. A submit tool appends its directive so the
|
||||
// instruction to call it lives with the tool, not in every prompt file.
|
||||
const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
const fullPrompt = context ? `${context}\n\n${prompt}` : prompt;
|
||||
const basePrompt = context ? `${context}\n\n${prompt}` : prompt;
|
||||
const fullPrompt = submitTool?.directive ? basePrompt + submitTool.directive : basePrompt;
|
||||
|
||||
// 2. Set up progress and audit infrastructure
|
||||
const execContext = detectExecutionContext(description);
|
||||
@@ -232,27 +251,32 @@ export async function runPiPrompt(
|
||||
? path.join(sourceDir, path.dirname(deliverablesSubdir), '.playwright-cli')
|
||||
: path.join(sourceDir, '.shannon', '.playwright-cli');
|
||||
if (deliverablesSubdir) process.env.SHANNON_DELIVERABLES_SUBDIR = deliverablesSubdir;
|
||||
if (apiKey) process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
|
||||
// 4. Resolve model + auth, then assemble the tool set (universal task/todo tools
|
||||
// plus any caller-supplied collector/submit tools).
|
||||
const selection = resolveModelSelection((auth) => ModelRegistry.create(auth), modelTier, apiKey, providerConfig);
|
||||
const resourceLoader = await buildResourceLoader(sourceDir, logger);
|
||||
// Accumulates cost from in-process `task` child sessions so the parent's reported
|
||||
const selection = resolveModelSelection((auth) => ModelRegistry.create(auth), modelTier);
|
||||
const resourceLoader = await buildResourceLoader(sourceDir, logger, agentName);
|
||||
// Accumulates usage from in-process `task` child sessions so the parent's reported
|
||||
// cost includes sub-agent spend (their getSessionStats is separate from ours).
|
||||
const childUsage = { cost: 0 };
|
||||
const childUsage = { cost: 0, inputTokens: 0, outputTokens: 0 };
|
||||
const customTools: ToolDefinition[] = [
|
||||
createTaskTool({
|
||||
model: selection.model,
|
||||
thinkingLevel: selection.thinkingLevel,
|
||||
authStorage: selection.authStorage,
|
||||
cwd: sourceDir,
|
||||
childUsage,
|
||||
onUsage: (usage) => {
|
||||
childUsage.cost += usage.cost;
|
||||
childUsage.inputTokens += usage.inputTokens;
|
||||
childUsage.outputTokens += usage.outputTokens;
|
||||
},
|
||||
resourceLoader,
|
||||
...(cancellationSignal && { cancellationSignal }),
|
||||
}),
|
||||
createTodoWriteTool(auditLogger),
|
||||
createGlobTool(sourceDir),
|
||||
...(callerTools ?? []),
|
||||
...(submitTool ? [submitTool.tool] : []),
|
||||
];
|
||||
// pi's `tools` allowlist gates custom tools too — list every custom name.
|
||||
const tools = [...BUILTIN_TOOLS, ...customTools.map((t) => t.name)];
|
||||
@@ -357,6 +381,10 @@ export async function runPiPrompt(
|
||||
const duration = timer.stop();
|
||||
progress.finish(formatCompletionMessage(execContext, description, turnCount, duration));
|
||||
|
||||
// Capture the submit tool's structured payload so callers read it off the
|
||||
// result instead of holding a reference to the tool.
|
||||
const structuredOutput = submitTool?.getCaptured();
|
||||
|
||||
return {
|
||||
result,
|
||||
success: true,
|
||||
@@ -366,6 +394,7 @@ export async function runPiPrompt(
|
||||
model: selection.model.id,
|
||||
partialCost: totalCost,
|
||||
apiErrorDetected,
|
||||
...(structuredOutput !== undefined && { structuredOutput }),
|
||||
};
|
||||
} catch (error) {
|
||||
// 10. Handle errors — log, write error file, return failure
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Per-session custom tools registered for every agent: `todo_write` and `glob`.
|
||||
*
|
||||
* These replace harness built-ins that pi does not ship. `todo_write` is a
|
||||
* full-state-replace planning scratchpad mirrored to the workflow log; `glob` is
|
||||
* fast-glob file matching (pi has no `Glob` built-in).
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { fs, glob, path } from 'zx';
|
||||
|
||||
import type { AuditLogger } from '../audit-logger.js';
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
function renderTodos(todos: readonly TodoItem[]): string {
|
||||
const mark = (status: TodoItem['status']): string => {
|
||||
if (status === 'completed') return 'x';
|
||||
if (status === 'in_progress') return '~';
|
||||
return ' ';
|
||||
};
|
||||
return todos.map((todo) => `[${mark(todo.status)}] ${todo.content}`).join(' ');
|
||||
}
|
||||
|
||||
export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
let current: TodoItem[] = [];
|
||||
|
||||
return defineTool({
|
||||
name: 'todo_write',
|
||||
label: 'Todo Write',
|
||||
description:
|
||||
'Use this tool to create and manage a structured task list for your current session. ' +
|
||||
'Pass the complete todo list on every call; it replaces the stored list entirely. Each ' +
|
||||
'todo has a status of pending, in_progress, or completed.',
|
||||
promptSnippet: 'todo_write: create and manage a structured task list',
|
||||
parameters: Type.Object({
|
||||
todos: Type.Array(
|
||||
Type.Object({
|
||||
content: Type.String({ description: 'Imperative task description, e.g. "Map SSRF sinks".' }),
|
||||
status: Type.Union([Type.Literal('pending'), Type.Literal('in_progress'), Type.Literal('completed')]),
|
||||
activeForm: Type.String({ description: 'Present-continuous form, e.g. "Mapping SSRF sinks".' }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
current = params.todos as TodoItem[];
|
||||
const completed = current.filter((todo) => todo.status === 'completed').length;
|
||||
await auditLogger.logNote('todo', renderTodos(current));
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Todos updated (${current.length} items, ${completed} completed).`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createGlobTool(cwd: string): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'glob',
|
||||
label: 'Glob',
|
||||
description:
|
||||
'Fast file pattern matching. Supports glob patterns like "**/*.ts" or "src/**/*.{js,ts}". ' +
|
||||
'Returns matching file paths sorted by modification time, most recent first.',
|
||||
promptSnippet: 'glob: find files by name pattern',
|
||||
parameters: Type.Object({
|
||||
pattern: Type.String({ description: 'The glob pattern to match files against.' }),
|
||||
path: Type.Optional(Type.String({ description: 'Directory to search in. Omit for the repository root.' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const searchRoot = params.path ? path.resolve(cwd, params.path) : cwd;
|
||||
const matches = await glob.globby(params.pattern, {
|
||||
cwd: searchRoot,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
onlyFiles: true,
|
||||
followSymbolicLinks: false,
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No files found' }], details: undefined };
|
||||
}
|
||||
|
||||
const withMtime = await Promise.all(
|
||||
matches.map(async (file) => {
|
||||
try {
|
||||
return { file, mtime: (await fs.stat(file)).mtimeMs };
|
||||
} catch {
|
||||
return { file, mtime: 0 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
withMtime.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: withMtime.map((match) => match.file).join('\n') }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Generic `task` tool — pi.dev ships no built-in Task tool, so this supplies the
|
||||
* Task-delegation surface Shannon's prompts require.
|
||||
*
|
||||
* Shannon's prompts mandate Task delegation (recon source tracer; the vuln
|
||||
* agents delegate *every* code review; the exploit agents delegate automation),
|
||||
* so this tool is required for parity, not optional. It spawns a nested pi
|
||||
* session with the parent's resolved model object (never a tier string — that
|
||||
* would route sub-agents through hardcoded IDs and leak billing), the parent's
|
||||
* resource loader, and a fixed child tool surface.
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
||||
import { type AssistantMessage, type Model, Type } from '@earendil-works/pi-ai';
|
||||
import {
|
||||
type AuthStorage,
|
||||
createAgentSession,
|
||||
defineTool,
|
||||
getAgentDir,
|
||||
type ModelRegistry,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
|
||||
export interface TaskToolContext {
|
||||
cwd: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
authStorage: AuthStorage;
|
||||
/** Explicit model registry for sub-session resolution. Omit to inherit the parent's default. */
|
||||
modelRegistry?: ModelRegistry;
|
||||
resourceLoader: ResourceLoader;
|
||||
cancellationSignal?: AbortSignal | undefined;
|
||||
/**
|
||||
* Reports the cost/tokens of each spawned sub-session back to the caller.
|
||||
* Sub-agents run in their own pi sessions that the parent has no reference to,
|
||||
* so without this their spend (the bulk of a whitebox run, since Shannon
|
||||
* prompts delegate the heavy work) is invisible to billing.
|
||||
*/
|
||||
onUsage?: (usage: { cost: number; inputTokens: number; outputTokens: number }) => void;
|
||||
}
|
||||
|
||||
const CHILD_TOOLS = ['read', 'grep', 'find', 'ls', 'write', 'bash'];
|
||||
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }], details: undefined };
|
||||
}
|
||||
|
||||
export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
const taskTool: ToolDefinition = defineTool({
|
||||
name: 'task',
|
||||
label: 'Task',
|
||||
description:
|
||||
'Delegate a focused task to a sub-agent that runs independently with its own tools and returns ' +
|
||||
'the result. Use this to break complex work into smaller, parallelizable sub-tasks.',
|
||||
executionMode: 'parallel',
|
||||
promptSnippet: 'task - Delegate a focused task to a sub-agent with read, grep, find, ls, write, and bash.',
|
||||
promptGuidelines: [
|
||||
'Use the task tool to delegate focused work: code review, reconnaissance, automation scripting, validation.',
|
||||
'Pass all necessary context in the "prompt" parameter — the sub-agent cannot see your conversation history.',
|
||||
'The sub-agent can use read, grep, find, ls, write, and bash, but cannot call task or custom collector tools.',
|
||||
'You can launch multiple task tool calls in a single message to run sub-tasks in parallel.',
|
||||
],
|
||||
parameters: Type.Object({
|
||||
prompt: Type.String({
|
||||
description: 'The task for the sub-agent to perform. Include all necessary context.',
|
||||
}),
|
||||
description: Type.Optional(Type.String({ description: 'A short (3-5 word) description of the task.' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const agentDir = getAgentDir();
|
||||
const { session: subSession } = await createAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir,
|
||||
resourceLoader: config.resourceLoader,
|
||||
model: config.model,
|
||||
...(config.thinkingLevel && { thinkingLevel: config.thinkingLevel }),
|
||||
tools: CHILD_TOOLS,
|
||||
authStorage: config.authStorage,
|
||||
...(config.modelRegistry && { modelRegistry: config.modelRegistry }),
|
||||
sessionManager: SessionManager.inMemory(config.cwd),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: { enabled: false },
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const abortChildSession = (): void => {
|
||||
void subSession.abort().catch(() => {
|
||||
// Parent logger is not available inside the tool; dispose still tears
|
||||
// down the session if abort itself rejects.
|
||||
});
|
||||
};
|
||||
const onCancellation = (): void => abortChildSession();
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
abortChildSession();
|
||||
} else {
|
||||
config.cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
let resultText = '';
|
||||
let subCost = 0;
|
||||
let subInputTokens = 0;
|
||||
let subOutputTokens = 0;
|
||||
subSession.subscribe((event) => {
|
||||
if (event.type === 'turn_end') {
|
||||
const msg = event.message as AssistantMessage | undefined;
|
||||
for (const block of msg?.content ?? []) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
resultText += (resultText ? '\n' : '') + block.text;
|
||||
}
|
||||
}
|
||||
if (msg?.usage?.cost?.total != null) subCost += msg.usage.cost.total;
|
||||
subInputTokens += msg?.usage?.input ?? 0;
|
||||
subOutputTokens += msg?.usage?.output ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
let swallowedError: string | undefined;
|
||||
try {
|
||||
try {
|
||||
await subSession.prompt(params.prompt);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
resultText += `\n[Sub-agent error: ${errorMsg}]`;
|
||||
}
|
||||
|
||||
swallowedError = subSession.state.errorMessage;
|
||||
// Read stats before dispose; reconcile cost the same way the parent does.
|
||||
const subStats = subSession.getSessionStats();
|
||||
if (subStats.cost > subCost) subCost = subStats.cost;
|
||||
config.onUsage?.({ cost: subCost, inputTokens: subInputTokens, outputTokens: subOutputTokens });
|
||||
} finally {
|
||||
config.cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
subSession.dispose();
|
||||
}
|
||||
|
||||
if (swallowedError && !resultText.includes(swallowedError)) {
|
||||
resultText += `\n[Sub-agent error: ${swallowedError}]`;
|
||||
}
|
||||
|
||||
return textResult(resultText || '[Sub-agent produced no output]');
|
||||
},
|
||||
});
|
||||
|
||||
return taskTool;
|
||||
}
|
||||
@@ -7,19 +7,21 @@
|
||||
/**
|
||||
* TypeBox schemas + submit-tool factory for vulnerability exploitation queues.
|
||||
*
|
||||
* pi has no JSON-schema output format, so each vuln agent's structured queue is
|
||||
* captured via a `submit_exploitation_queue` custom tool whose parameters mirror
|
||||
* the per-class schema below. The captured payload is written to
|
||||
* `<class>_exploitation_queue.json` by the caller (agent-execution).
|
||||
* pi captures each vuln agent's structured queue via a `submit_exploitation_queue`
|
||||
* custom tool whose parameters mirror the per-class schema below. Entry types are
|
||||
* derived from the same schemas and consumed by the findings renderer.
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { defineTool } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, type TObject, Type } from 'typebox';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import type { CapturedSubmitTool } from './submit-tool.js';
|
||||
|
||||
const ANALYSIS_NOTES_DESCRIPTION = 'Plain context for defenders (caveats, scope, what is at risk). Not attack steps.';
|
||||
|
||||
const optStr = (description?: string) => Type.Optional(Type.String(description ? { description } : {}));
|
||||
function optStr(description?: string) {
|
||||
return Type.Optional(Type.String(description === undefined ? {} : { description }));
|
||||
}
|
||||
|
||||
/** Base fields shared by every queue entry. `notes` gains guidance in analysis mode. */
|
||||
function baseFields(exploit: boolean) {
|
||||
@@ -84,6 +86,20 @@ const authzFields = {
|
||||
minimal_witness: optStr(),
|
||||
};
|
||||
|
||||
// === Per-entry schemas (single vulnerability). Entry types derive from these. ===
|
||||
|
||||
const injectionEntry = () => Type.Object({ ...baseFields(true), ...injectionFields });
|
||||
const xssEntry = () => Type.Object({ ...baseFields(true), ...xssFields });
|
||||
const authEntry = () => Type.Object({ ...baseFields(true), ...authFields });
|
||||
const ssrfEntry = () => Type.Object({ ...baseFields(true), ...ssrfFields });
|
||||
const authzEntry = () => Type.Object({ ...baseFields(true), ...authzFields });
|
||||
|
||||
export type InjectionFinding = Static<ReturnType<typeof injectionEntry>>;
|
||||
export type XssFinding = Static<ReturnType<typeof xssEntry>>;
|
||||
export type AuthFinding = Static<ReturnType<typeof authEntry>>;
|
||||
export type SsrfFinding = Static<ReturnType<typeof ssrfEntry>>;
|
||||
export type AuthzFinding = Static<ReturnType<typeof authzEntry>>;
|
||||
|
||||
const PER_TYPE_FIELDS: Partial<Record<AgentName, Record<string, ReturnType<typeof optStr>>>> = {
|
||||
'injection-vuln': injectionFields,
|
||||
'xss-vuln': xssFields,
|
||||
@@ -92,28 +108,6 @@ const PER_TYPE_FIELDS: Partial<Record<AgentName, Record<string, ReturnType<typeo
|
||||
'authz-vuln': authzFields,
|
||||
};
|
||||
|
||||
/** Build the `{ vulnerabilities: [...] }` queue schema for an agent + mode. */
|
||||
function queueSchema(agentName: AgentName, exploit: boolean): TObject | null {
|
||||
const extra = PER_TYPE_FIELDS[agentName];
|
||||
if (!extra) return null;
|
||||
return Type.Object({
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ...extra })),
|
||||
});
|
||||
}
|
||||
|
||||
// === Inferred entry types (consumed by renderers) ===
|
||||
export type InjectionFinding = Static<ReturnType<typeof injectionEntry>>;
|
||||
export type XssFinding = Static<ReturnType<typeof xssEntry>>;
|
||||
export type AuthFinding = Static<ReturnType<typeof authEntry>>;
|
||||
export type SsrfFinding = Static<ReturnType<typeof ssrfEntry>>;
|
||||
export type AuthzFinding = Static<ReturnType<typeof authzEntry>>;
|
||||
|
||||
const injectionEntry = () => Type.Object({ ...baseFields(true), ...injectionFields });
|
||||
const xssEntry = () => Type.Object({ ...baseFields(true), ...xssFields });
|
||||
const authEntry = () => Type.Object({ ...baseFields(true), ...authFields });
|
||||
const ssrfEntry = () => Type.Object({ ...baseFields(true), ...ssrfFields });
|
||||
const authzEntry = () => Type.Object({ ...baseFields(true), ...authzFields });
|
||||
|
||||
const VULN_AGENT_QUEUE_FILENAMES: Partial<Record<AgentName, string>> = {
|
||||
'injection-vuln': 'injection_exploitation_queue.json',
|
||||
'xss-vuln': 'xss_exploitation_queue.json',
|
||||
@@ -122,38 +116,53 @@ const VULN_AGENT_QUEUE_FILENAMES: Partial<Record<AgentName, string>> = {
|
||||
'authz-vuln': 'authz_exploitation_queue.json',
|
||||
};
|
||||
|
||||
/** Build the TypeBox submit-tool parameters for a vuln agent, or undefined for non-vuln agents. */
|
||||
function queueSchema(agentName: AgentName, exploit: boolean): TObject | undefined {
|
||||
const extra = PER_TYPE_FIELDS[agentName];
|
||||
if (!extra) return undefined;
|
||||
return Type.Object({
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ...extra })),
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the queue filename for a vuln agent, or undefined for non-vuln agents. */
|
||||
export function getQueueFilename(agentName: AgentName): string | undefined {
|
||||
return VULN_AGENT_QUEUE_FILENAMES[agentName];
|
||||
}
|
||||
|
||||
export interface QueueSubmitTool {
|
||||
tool: ToolDefinition;
|
||||
getCaptured: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `submit_exploitation_queue` tool for a vuln agent, or null for
|
||||
* non-vuln agents. The agent calls it once with the full findings list; the
|
||||
* captured payload is the structured queue.
|
||||
*/
|
||||
export function createQueueSubmitTool(agentName: AgentName, exploit: boolean): QueueSubmitTool | null {
|
||||
/** Build the pi submit tool that captures the exploitation queue for vuln agents. */
|
||||
export function createQueueSubmitTool(agentName: AgentName, exploit = true): CapturedSubmitTool | undefined {
|
||||
const schema = queueSchema(agentName, exploit);
|
||||
if (!schema) return null;
|
||||
let captured: unknown;
|
||||
const tool = defineTool({
|
||||
name: 'submit_exploitation_queue',
|
||||
label: 'Submit Exploitation Queue',
|
||||
description:
|
||||
'Submit the final structured list of analyzed vulnerabilities for this class. Call exactly once when ' +
|
||||
'analysis is complete, with every finding included.',
|
||||
promptSnippet: 'submit_exploitation_queue: record the final structured findings list (call once)',
|
||||
parameters: schema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
captured = params;
|
||||
const count = (params as { vulnerabilities?: unknown[] }).vulnerabilities?.length ?? 0;
|
||||
return { content: [{ type: 'text' as const, text: `Recorded ${count} findings.` }], details: {} };
|
||||
},
|
||||
});
|
||||
return { tool, getCaptured: () => captured };
|
||||
if (!schema) return undefined;
|
||||
|
||||
let captured: unknown | undefined;
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_exploitation_queue',
|
||||
label: 'Submit Exploitation Queue',
|
||||
description:
|
||||
'Submit the final structured list of analyzed vulnerabilities for this class. Call exactly once when analysis is complete.',
|
||||
promptSnippet: 'submit_exploitation_queue: record the final structured findings list (call once)',
|
||||
promptGuidelines: [
|
||||
'You MUST call submit_exploitation_queue exactly once as your final action.',
|
||||
'Include every analyzed finding in the vulnerabilities array.',
|
||||
],
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, params) {
|
||||
captured = params;
|
||||
const count = Array.isArray((params as { vulnerabilities?: unknown }).vulnerabilities)
|
||||
? (params as { vulnerabilities: unknown[] }).vulnerabilities.length
|
||||
: 0;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Recorded ${count} findings.` }],
|
||||
details: params,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
directive:
|
||||
'\n\nYou MUST call the submit_exploitation_queue tool exactly once as your final action ' +
|
||||
'to deliver your structured exploitation queue. Do not output JSON as text. Fill every required parameter.',
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Writes the @gotgenes/pi-permission-system global config from `code_path` avoid
|
||||
* patterns. The executor loads the extension (see pi-executor) and pi enforces
|
||||
* these path denies at the tool layer for every agent. Written to the global config
|
||||
* dir under `agentDir` — the project-scoped path is gated behind project trust,
|
||||
* which our headless runs do not grant; the global path is not.
|
||||
*/
|
||||
|
||||
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
||||
import { fs, path } from 'zx';
|
||||
import type { DistributedConfig } from '../types/config.js';
|
||||
|
||||
/** Absolute path to the pi-permission-system global config.json. */
|
||||
export function permissionConfigPath(): string {
|
||||
return path.join(getAgentDir(), 'extensions', 'pi-permission-system', 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (or remove) the pi-permission-system config derived from `code_path`
|
||||
* avoid patterns.
|
||||
*
|
||||
* Each avoid maps to a cross-cutting `path` deny — the strongest surface, blocking
|
||||
* the path across every tool and bash command, and not overridable by a per-tool
|
||||
* allow. `"*": "allow"` keeps everything else permitted so the extension does not
|
||||
* fall back to its default `ask` (which would block all access headlessly). When
|
||||
* there are no avoids the config is removed, so the executor skips loading the
|
||||
* extension entirely.
|
||||
*/
|
||||
export async function writeCodePathPermissionConfig(config: DistributedConfig | null): Promise<void> {
|
||||
const avoidPatterns = (config?.avoid ?? []).filter((r) => r.type === 'code_path').map((r) => r.value);
|
||||
const configPath = permissionConfigPath();
|
||||
|
||||
if (avoidPatterns.length === 0) {
|
||||
await fs.remove(configPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// pi's matcher (wildcard-matcher.ts) has NO `**` globstar — it splits on each `*`
|
||||
// and joins with `.*`, and a single `*` already matches any chars incl. `/`. Tool
|
||||
// paths are compared as absolute (path-utils resolves them against cwd), so we
|
||||
// collapse `**`→`*` and add a `*/`-prefixed variant that matches the path under
|
||||
// any repo prefix. (A bare pattern never matches an absolute path.)
|
||||
const pathDeny: Record<string, 'allow' | 'deny'> = { '*': 'allow' };
|
||||
for (const pattern of avoidPatterns) {
|
||||
const clean = pattern.replace(/^[./]+/, '').replace(/\*\*/g, '*');
|
||||
// Deny the contents (under any repo prefix and as written)...
|
||||
pathDeny[`*/${clean}`] = 'deny';
|
||||
pathDeny[clean] = 'deny';
|
||||
// ...and the folder path itself, so the directory entry is denied too — the
|
||||
// contents patterns (…/*) require a trailing segment and wouldn't match it.
|
||||
if (clean.endsWith('/*')) {
|
||||
const folder = clean.slice(0, -2);
|
||||
if (folder) {
|
||||
pathDeny[`*/${folder}`] = 'deny';
|
||||
pathDeny[folder] = 'deny';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const permissionConfig = {
|
||||
permission: {
|
||||
'*': 'allow',
|
||||
path: pathDeny,
|
||||
},
|
||||
};
|
||||
|
||||
await fs.ensureDir(path.dirname(configPath));
|
||||
await fs.writeJson(configPath, permissionConfig, { spaces: 2 });
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
|
||||
/**
|
||||
* A pi custom submit tool plus the captured payload it records.
|
||||
*
|
||||
* pi ships no JSON-schema output format, so an agent that must return structured
|
||||
* data does so by calling a purpose-built TypeBox tool. This bundles that tool
|
||||
* with its capture accessor and the directive that instructs the model to call
|
||||
* it. The executor owns the wiring — it registers the tool, appends the
|
||||
* directive to the prompt, and reads `getCaptured()` back as `structuredOutput`
|
||||
* — so callers never assemble it by hand.
|
||||
*/
|
||||
export interface CapturedSubmitTool {
|
||||
readonly tool: ToolDefinition;
|
||||
readonly getCaptured: () => unknown | undefined;
|
||||
readonly directive?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `submit_result` tool from a raw JSON Schema, for agents whose result
|
||||
* shape is not one of the built-in per-agent schemas (e.g. an out-of-tree agent
|
||||
* with its own verdict schema). pi validates the tool call against `schema`
|
||||
* before `execute()` runs, so a captured payload is already schema-valid — no
|
||||
* separate validation pass is needed.
|
||||
*/
|
||||
export function createGenericSubmitTool(schema: Record<string, unknown>): CapturedSubmitTool {
|
||||
let captured: unknown | undefined;
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_result',
|
||||
label: 'Submit Result',
|
||||
description: 'Return your final structured answer. Call exactly once as your last action.',
|
||||
promptSnippet: 'submit_result: deliver your structured answer (call once)',
|
||||
promptGuidelines: [
|
||||
'You MUST call submit_result exactly once as your final action.',
|
||||
'Fill every required parameter. Do not output JSON as text.',
|
||||
],
|
||||
parameters: Type.Unsafe(schema),
|
||||
async execute(_toolCallId, params) {
|
||||
captured = params;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Result submitted.' }],
|
||||
details: params,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
directive:
|
||||
'\n\nYou MUST call the submit_result tool exactly once as your final action ' +
|
||||
'to deliver your structured answer. Do not output JSON as text. Fill every required parameter.',
|
||||
};
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Universal custom tools registered for every agent: `task`, `todo_write`, and `glob`.
|
||||
*
|
||||
* These replace harness built-ins that pi does not ship. `task` delegates a focused
|
||||
* sub-task to an in-process child session (the Task sub-agent replacement);
|
||||
* `todo_write` is a full-state-replace planning scratchpad mirrored to the workflow
|
||||
* log; `glob` is fast-glob file matching (pi has no `Glob` built-in).
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
||||
import type { Api, Model } from '@earendil-works/pi-ai';
|
||||
import {
|
||||
type AuthStorage,
|
||||
createAgentSession,
|
||||
defineTool,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { fs, glob, path } from 'zx';
|
||||
import type { AuditLogger } from './audit-logger.js';
|
||||
|
||||
/** Tool surface for child sessions: read/search plus `write`+`bash` to author and run scripts. */
|
||||
const CHILD_TOOLS = ['read', 'grep', 'find', 'ls', 'write', 'bash'];
|
||||
|
||||
export interface TaskToolContext {
|
||||
model: Model<Api>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
authStorage: AuthStorage;
|
||||
cwd: string;
|
||||
/** When set, child sessions inherit the code_path deny policy. */
|
||||
resourceLoader?: ResourceLoader;
|
||||
/**
|
||||
* Mutable accumulator: each child (sub-agent) session's cost is added here so the
|
||||
* parent executor can include sub-agent spend in its reported cost. Child sessions
|
||||
* keep their own `getSessionStats`, separate from the parent's.
|
||||
*/
|
||||
childUsage?: { cost: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `task` tool — launch a new agent to handle a multi-step task autonomously.
|
||||
*
|
||||
* Spawns an in-process child session, drives it to completion, and returns its
|
||||
* final text. Marked `parallel` for one-turn fan-out. Children get no `task` of
|
||||
* their own — delegation is one level.
|
||||
*/
|
||||
export function createTaskTool(ctx: TaskToolContext): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'task',
|
||||
label: 'Task',
|
||||
description:
|
||||
'Launch a new agent to handle complex, multi-step tasks autonomously. The agent runs on its own and ' +
|
||||
'its final report is returned to you as the tool result (it is not shown to the user). Each invocation ' +
|
||||
'is stateless — you cannot send follow-up messages, so give a complete, detailed instruction in a single ' +
|
||||
'prompt and specify exactly what information the agent should return. Launch multiple agents concurrently ' +
|
||||
'by issuing multiple task calls in a single message.',
|
||||
promptSnippet: 'task: launch a new agent to handle a multi-step task',
|
||||
executionMode: 'parallel',
|
||||
parameters: Type.Object({
|
||||
description: Type.Optional(Type.String({ description: 'Short (3-5 word) label for the delegated sub-task.' })),
|
||||
prompt: Type.String({ description: 'The full instruction for the sub-agent.' }),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const { session: child } = await createAgentSession({
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
thinkingLevel: ctx.thinkingLevel,
|
||||
tools: CHILD_TOOLS,
|
||||
authStorage: ctx.authStorage,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: { enabled: false },
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
...(ctx.resourceLoader && { resourceLoader: ctx.resourceLoader }),
|
||||
});
|
||||
try {
|
||||
await child.prompt(params.prompt);
|
||||
const text = child.getLastAssistantText() ?? '(sub-agent produced no output)';
|
||||
return { content: [{ type: 'text' as const, text }], details: {} };
|
||||
} finally {
|
||||
// Roll the child's cost up to the parent before disposing (best-effort, and
|
||||
// captured in `finally` so a failed child's partial spend still counts).
|
||||
if (ctx.childUsage) {
|
||||
try {
|
||||
ctx.childUsage.cost += child.getSessionStats().cost;
|
||||
} catch {
|
||||
// ignore — cost capture is best-effort
|
||||
}
|
||||
}
|
||||
child.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
/** Render a todo list as a compact checklist for the workflow log. */
|
||||
function renderTodos(todos: readonly TodoItem[]): string {
|
||||
const mark = (s: TodoItem['status']): string => (s === 'completed' ? 'x' : s === 'in_progress' ? '~' : ' ');
|
||||
return todos.map((t) => `[${mark(t.status)}] ${t.content}`).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `todo_write` tool — a full-state-replace planning scratchpad.
|
||||
*
|
||||
* Mirrors the TodoWrite tool: each call carries the entire list and replaces
|
||||
* stored state (no append/merge). No deliverable impact; every call is echoed to
|
||||
* the workflow log so `shannon logs` shows the agent's live plan. State is per
|
||||
* tool instance (one per agent execution).
|
||||
*/
|
||||
export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
let current: TodoItem[] = [];
|
||||
return defineTool({
|
||||
name: 'todo_write',
|
||||
label: 'Todo Write',
|
||||
description:
|
||||
'Use this tool to create and manage a structured task list for your current session. This helps you ' +
|
||||
'track progress and organize complex, multi-step work, and gives visibility into what you are doing. ' +
|
||||
'Pass the COMPLETE todo list on every call — it replaces the stored list entirely (no append or merge). ' +
|
||||
'Each todo has a status of pending, in_progress, or completed; keep exactly one task in_progress at a ' +
|
||||
'time and mark a task completed as soon as it is finished.',
|
||||
promptSnippet: 'todo_write: create and manage a structured task list',
|
||||
parameters: Type.Object({
|
||||
todos: Type.Array(
|
||||
Type.Object({
|
||||
content: Type.String({ description: 'Imperative task description, e.g. "Map SSRF sinks".' }),
|
||||
status: Type.Union([Type.Literal('pending'), Type.Literal('in_progress'), Type.Literal('completed')]),
|
||||
activeForm: Type.String({ description: 'Present-continuous form, e.g. "Mapping SSRF sinks".' }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
current = params.todos as TodoItem[];
|
||||
const completed = current.filter((t) => t.status === 'completed').length;
|
||||
await auditLogger.logNote('todo', renderTodos(current));
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Todos updated (${current.length} items, ${completed} completed).` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `glob` tool — fast file pattern matching (pi ships no `Glob` built-in).
|
||||
*
|
||||
* Backed by the same fast-glob engine that classifies code_path rules as `[GLOB]`
|
||||
* (see utils/glob.ts `isGlobPattern`), so it enumerates exactly the patterns the
|
||||
* routing tags as globs — including `**` and `{a,b}`, which pi's `find` would not
|
||||
* match the same way. Returns absolute paths, most-recently-modified first.
|
||||
*/
|
||||
export function createGlobTool(cwd: string): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'glob',
|
||||
label: 'Glob',
|
||||
description:
|
||||
'Fast file pattern matching. Supports glob patterns like "**/*.ts" or "src/**/*.{js,ts}". Returns ' +
|
||||
'matching file paths sorted by modification time (most recent first), one per line, or "No files found".',
|
||||
promptSnippet: 'glob: find files by name pattern',
|
||||
parameters: Type.Object({
|
||||
pattern: Type.String({ description: 'The glob pattern to match files against.' }),
|
||||
path: Type.Optional(Type.String({ description: 'Directory to search in. Omit to search the repository root.' })),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const searchRoot = params.path ? path.resolve(cwd, params.path) : cwd;
|
||||
const matches = await glob.globby(params.pattern, {
|
||||
cwd: searchRoot,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
onlyFiles: true,
|
||||
followSymbolicLinks: false,
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No files found' }], details: {} };
|
||||
}
|
||||
// Sort by mtime (most recent first) to match the canonical Glob contract.
|
||||
const withMtime = await Promise.all(
|
||||
matches.map(async (file) => {
|
||||
try {
|
||||
return { file, mtime: (await fs.stat(file)).mtimeMs };
|
||||
} catch {
|
||||
return { file, mtime: 0 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
withMtime.sort((a, b) => b.mtime - a.mtime);
|
||||
return { content: [{ type: 'text' as const, text: withMtime.map((m) => m.file).join('\n') }], details: {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user