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:
ezl-keygraph authored and GitHub committed 2026-07-16 14:09:02 +05:30
1 parent b845936aa5
commit d0b0ec3378
47 files changed
+2007 -1334

No files matched your search

+21 -41
View File
@@ -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) {
+141
View File
@@ -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
+116
View File
@@ -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,
};
},
});
}
+156
View File
@@ -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;
}
+65 -56
View File
@@ -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.',
};
}
-75
View File
@@ -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 });
}
+60
View File
@@ -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.',
};
}
-205
View File
@@ -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: {} };
},
});
}