Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions REBUILD-INSTRUCTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Pentestcode Rebuild — Full Instruction

Состояние: 2026-11-10. Бинарь пересобран с 6 харнесс-фиксами.

## 6 фиксов (fork 7megaumka7/pentestcode)

| Commit | Фиксы |
|---|---|
| `7d6d853` | resolveVulnWrite (evidence gate), atomicWriteFileSync (temp+fsync+rename), withFileLock (O_EXCL lockfile) |
| `a822c84` | relationship-ID (резолв title→stable .id), agent-liveness (exp-backoff+jitter auto-retry), auto-chain-link (auto-edge после add_vuln) |

## Rebuild process

```bash
git clone https://github.com/7megaumka7/pentestcode.git # HEAD = a822c84
cd pentestcode

# FIX (обязательно): photon-node patch дрейфнул и не применяется.
# 1. package.json -> patchedDependencies -> УДАЛИТЬ "@silvia-odwyer/photon-node@0.3.4"
# 2. удалить файл patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch

bun install
cd packages/opencode && bun run build --single --skip-embed-web-ui
# готовый бинарь: packages/opencode/dist/pentestcode-linux-x64/bin/pentestcode
```

## Replace installed binary (ПОСЛЕ остановки текущей сессии)

```bash
NEW=/tmp/pc-src/packages/opencode/dist/pentestcode-linux-x64/bin/pentestcode
INSTALLED=~/.npm-global/lib/node_modules/pentestcode-ai/bin/pentestcode.exe
# На ходу заменить НЕЛЬЗЯ — "Text file busy" (бинарь запущен).
# 1. остановить текущую сессию pentestcode
cp "$INSTALLED" "$INSTALLED.bak-$(date +%Y%m%dT%H%M%SZ)"
cp "$NEW" "$INSTALLED"
chmod +x "$INSTALLED"
```

## Verify (после рестарта)

```bash
pentestcode --version # ожидается 0.0.0-main-202611092119
~/.pentestcode/bin/bootstrap # проверяет Burp + окружение + тулзы
```

## Что закрывают 6 фиксов

- write-gate 0% (record-finding обходится, тонкие confirmed) → resolveVulnWrite
- state corruption / lost-update (двойная запись) → atomicWriteFileSync + withFileLock
- dangling chain edges (6 «unresolved», title-хрупкие) → relationship-ID
- dead-dispatch 19% (осиротевшие агенты) → agent-liveness
- chain-density 0% (забываю link) → auto-chain-link

## Тесты (перед сборкой)

- bun test (core): 1176 pass / 0 fail
- typecheck clean, oxlint 0 errors
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
Expand All @@ -143,4 +142,4 @@
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
}
}
}
217 changes: 217 additions & 0 deletions packages/core/src/engagement/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,220 @@ export function detectStall(
}
return stalls.sort((a, b) => b.idleMs - a.idleMs)
}

// --- Agent-liveness auto-retry (silent-subagent watchdog) ---
// `detectStall` guards the DAG-deadlock case (a stalled task strands its
// DEPENDENTS). This probe guards a different failure: a subagent that is still
// "in flight" and still emitting output, but has produced ZERO new state markers
// (state_update mutations that change engagement state) for N minutes — it is
// busy-but-unproductive, and its node never completes so the wave never goes
// quiescent. For each such node we compute a ONE-SHOT auto-retry plan: a reduced
// scope plus an exponential-backoff-with-jitter delay. Exactly ONE retry is
// planned per node (`maxRetries` default 1); the caller (coordinator/harness
// driver) is responsible for actually dispatching the retry — this module, like
// the rest of orchestrator, computes and never spawns.

export interface LivenessRetry {
/** The in-flight task whose subagent has produced no new state markers. */
node: TaskGraph.TaskNode
/** How long (ms) since the subagent's last state marker (fallback: dispatch age). */
silentMs: number
/** Reduced-scope instruction for the retry (narrower than the original task). */
reducedScope: string
/** Delay (ms) before the retry should launch — exponential backoff + jitter. */
delayMs: number
/** Zero-based retry attempt index this plan is for (0 = first retry). */
attempt: number
}

/**
* Exponential backoff with full jitter, bounded to [baseDelayMs, maxDelayMs].
* `random` is injectable so tests can pin the jitter term; defaults to
* Math.random. A jitter of 0 disables jitter (deterministic backoff).
*/
export function retryDelayMs(
attempt: number,
opts: {
baseDelayMs?: number
maxDelayMs?: number
jitter?: number
random?: () => number
} = {},
): number {
const base = opts.baseDelayMs ?? 30_000
const max = opts.maxDelayMs ?? 300_000
const jitter = opts.jitter ?? 0.2
const random = opts.random ?? Math.random
const exponential = Math.min(base * 2 ** attempt, max)
const spread = exponential * jitter
const delay = exponential + (random() * 2 - 1) * spread
return Math.round(Math.max(0, Math.min(max, delay)))
}

/**
* Narrow a task into a reduced-scope retry instruction. The retry focuses on the
* single named target (dropping broad multi-vector technique hints) or, absent a
* target, on the single highest-value sub-vector of the description.
*/
function reducedScopeFor(node: TaskGraph.TaskNode): string {
const focus = node.target?.trim()
if (focus) {
return `Auto-retry (reduced scope): focus ONLY on ${focus}${node.technique ? ` — drop broad "${node.technique}" enumeration` : ""}. Original: "${node.description}"`
}
return `Auto-retry (reduced scope): focus ONLY on the single highest-value sub-vector of "${node.description}"`
}

/**
* Detect in-flight subagents that have produced ZERO new state markers for
* `markerTimeoutMs` and emit a one-shot auto-retry plan (reduced scope +
* exponential-backoff-with-jitter delay). A node is eligible only while its
* retry count is below `maxRetries` (default 1), so at most ONE retry is planned
* per node. Sorted most-silent-first. Pure: `nowMs` supplied by the caller.
*/
export function detectStaleAgents(
graph: TaskGraph.TaskNodes,
opts: {
markerTimeoutMs: number
nowMs: number
/** nodeId → epoch ms of the subagent's last state marker (state mutation). */
lastStateMarkerMs?: Record<string, number>
/** nodeId → retries already issued for that node. */
retryCount?: Record<string, number>
maxRetries?: number
baseDelayMs?: number
maxDelayMs?: number
jitter?: number
random?: () => number
},
): LivenessRetry[] {
const maxRetries = opts.maxRetries ?? 1
const out: LivenessRetry[] = []
for (const node of TaskGraph.getRunning(graph)) {
const lastMarker = opts.lastStateMarkerMs?.[node.id]
const lastSeen = lastMarker !== undefined ? lastMarker : Date.parse(node.updatedAt)
const silentMs = opts.nowMs - lastSeen
if (!Number.isFinite(silentMs) || silentMs < opts.markerTimeoutMs) continue
const retries = opts.retryCount?.[node.id] ?? 0
if (retries >= maxRetries) continue
out.push({
node,
silentMs,
reducedScope: reducedScopeFor(node),
delayMs: retryDelayMs(retries, {
baseDelayMs: opts.baseDelayMs,
maxDelayMs: opts.maxDelayMs,
jitter: opts.jitter,
random: opts.random,
}),
attempt: retries,
})
}
return out.sort((a, b) => b.silentMs - a.silentMs)
}

// --- Burp proxy env injection (mandatory assessment layer) ---
// Every subagent's HTTP(S) egress must route through the Burp proxy at
// 127.0.0.1:8080 so the proxy history fills and Active Scan++ / Backslash /
// Autorize have a complete request corpus to work from. These are COMPUTE
// helpers: they return the env vars (and the prompt snippet) the spawn driver
// must apply, honoring an explicit opt-out (e.g. an offline binary-build task
// whose loopback traffic must not be re-proxied). Like the rest of orchestrator,
// they never spawn — the driver is the caller's responsibility.

export const BURP_PROXY_URL = "http://127.0.0.1:8080"

/** Loopback hosts the proxy must NOT re-proxy (Burp itself + local control plane). */
export const BURP_NO_PROXY = "127.0.0.1,localhost,::1"

export interface ProxyEnvOptions {
/** Explicit opt-out: return empty env / empty prompt when true. */
disableProxy?: boolean
/** Override the default proxy URL (defaults to BURP_PROXY_URL). */
proxyUrl?: string
/** The subagent this env is computed for (checked against optOutAgents). */
assignedAgent?: string
/** Assigned-agent names that explicitly opt out of proxying. */
optOutAgents?: readonly string[]
}

/** True when the subagent has explicitly opted out of proxy injection. */
export function proxyDisabled(opts: ProxyEnvOptions): boolean {
if (opts.disableProxy) return true
if (opts.assignedAgent && opts.optOutAgents?.includes(opts.assignedAgent)) return true
return false
}

/**
* Compute the HTTP(S)_PROXY env vars to inject into a subagent's environment so
* all of its egress routes through Burp. Returns an empty object when the
* subagent opts out. Loopback is exempt via NO_PROXY so Burp itself and the
* harness's local control plane are not re-proxied (which would loop).
*/
export function proxyEnvForSubagent(opts: ProxyEnvOptions = {}): Record<string, string> {
if (proxyDisabled(opts)) return {}
const url = opts.proxyUrl ?? BURP_PROXY_URL
return {
HTTP_PROXY: url,
HTTPS_PROXY: url,
http_proxy: url,
https_proxy: url,
NO_PROXY: BURP_NO_PROXY,
no_proxy: BURP_NO_PROXY,
}
}

/**
* Prompt snippet telling the subagent its traffic is proxied and must stay so —
* injected into the subagent's instructions so it does not unset or bypass the
* proxy env (which would leave the assessment layer blind for that task).
*/
export function proxyEnvPrompt(opts: ProxyEnvOptions = {}): string {
if (proxyDisabled(opts)) return ""
const url = opts.proxyUrl ?? BURP_PROXY_URL
return (
`Traffic policy: all HTTP(S) egress is routed through the Burp proxy at ${url} ` +
`(HTTP_PROXY/HTTPS_PROXY are pre-set in your environment). Do not unset or bypass them — ` +
`the proxy fills assessment coverage (Active Scan++ / Backslash / Autorize). ` +
`Loopback (${BURP_NO_PROXY}) is exempt via NO_PROXY.`
)
}

/**
* One-call interface for the spawn driver: the env map plus the prompt snippet
* for a given task node, honoring a node-level opt-out via its assignedAgent.
*/
export function subagentProxy(
node: TaskGraph.TaskNode,
opts: ProxyEnvOptions = {},
): { env: Record<string, string>; prompt: string } {
const merged: ProxyEnvOptions = { ...opts, assignedAgent: opts.assignedAgent ?? node.assignedAgent }
return { env: proxyEnvForSubagent(merged), prompt: proxyEnvPrompt(merged) }
}

/**
* Session-metadata key the spawn driver uses to stash a subagent's proxy env so
* the shell spawn (bash tool / shell method) can read it back and inject
* HTTP_PROXY/HTTPS_PROXY into the child process. The env is computed ONCE at
* spawn (where the task node / assignedAgent is known, and where opt-out is
* decided) and carried through the session — the shell spawn has no task-node
* context, so it must not re-derive (and re-decide opt-out) independently.
*/
export const SUBAGENT_PROXY_ENV_METADATA_KEY = "proxyEnv"

/**
* Read the proxy env a spawn driver stashed in a session's `metadata` (returned
* by `subagentProxy(..).env`). Returns an empty map when absent, non-object, or
* when the stored value is not a flat string map — so a malformed/metadata-less
* session degrades to "no proxy injection" instead of throwing inside the shell
* hot path.
*/
export function proxyEnvFromSession(metadata: unknown): Record<string, string> {
if (!metadata || typeof metadata !== "object") return {}
const env = (metadata as Record<string, unknown>)[SUBAGENT_PROXY_ENV_METADATA_KEY]
if (!env || typeof env !== "object") return {}
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
if (typeof value === "string") out[key] = value
}
return out
}
Loading