Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0042dbb
F2b: per-item execution and the runner's commit step
mchwang Sep 27, 2026
8e57ddb
Pass the runner owner token into execution deps
mchwang Sep 29, 2026
fe4b427
Cover task storage release on the foreign-result and launch-failure p…
mchwang Sep 29, 2026
c1f46ee
Fix the independent review of F2b: runner-owned findings, storage on …
mchwang Sep 30, 2026
3b59a5b
Fix round 2 of the F2b review: keep scope findings, check before the …
mchwang Sep 30, 2026
d8713cc
Fix round 3 of the F2b review: fail-closed manifests, a pause that ca…
mchwang Sep 30, 2026
ea717f6
Fix round 4 of the F2b review: pauses that neither stick nor get skipped
mchwang Sep 30, 2026
a6b04e5
Fix round 5 of the F2b review: fail closed after a scope pause, canon…
mchwang Sep 30, 2026
c0dfa90
Fix round 6 of the F2b review: escalate violations over any open stat…
mchwang Sep 30, 2026
4af7574
Fix round 7 of the F2b review: owed safety findings, human gates kept…
mchwang Sep 30, 2026
b3196c7
Fix round 8 of the F2b review: review statuses escalate, Git's .git s…
mchwang Sep 30, 2026
c91dfa7
Fix round 9 of the F2b review: scope pauses over review statuses, NTF…
mchwang Sep 30, 2026
1f1da60
Fix round 10 of the F2b review: a split case-only rename is one path
mchwang Sep 30, 2026
87e6289
Fix round 11 of the F2b review: a split rename is exactly one delete …
mchwang Sep 30, 2026
b7bb7ce
Fix round 12 of the F2b review: raw link targets, quoted preparation …
mchwang Sep 30, 2026
dd1a837
Fix round 13 of the F2b review: quote every preparation error, test s…
mchwang Sep 30, 2026
50993be
Fix round 14 of the F2b review: quote agent stderr, harden release-ho…
mchwang Sep 30, 2026
69bd7c3
Fix round 15 of the F2b review: refuse Windows path forms in link tar…
mchwang Sep 30, 2026
ebd25c6
Fix round 16 of the F2b review: a new run's start settles nothing of …
mchwang Sep 30, 2026
d503def
Fix round 17 of the F2b review: find every owed scope pause, cover th…
mchwang Sep 30, 2026
f58f2cc
Fix round 18 of the F2b review: compare the whole context between ite…
mchwang Sep 30, 2026
fe99203
Fix round 19 of the F2b review: one run per task at a time, owed outc…
mchwang Sep 30, 2026
955149d
Cover round 20 of the F2b review: every owed not-started path, the ou…
mchwang Sep 30, 2026
86c7a5f
Cover round 21 of the F2b review: a pause at a later item, an older o…
mchwang Oct 1, 2026
7bbfa73
Fix round 22 of the F2b review: no control characters in the commit t…
mchwang Oct 1, 2026
5a7c1c8
Fix round 23 of the F2b review: no bidi or format characters in the c…
mchwang Oct 1, 2026
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
117 changes: 102 additions & 15 deletions core/run-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,71 @@ export type AuditOutcome =
| { kind: 'commit'; inScope: string[]; outOfScope: string[]; unchanged: boolean; needsAmendment: boolean };

const MAX_CHANGES = 10_000;
/** Every path in the report together; keeps the saved result (scope lists included) far below its 1 MiB limit. */
const MAX_PATH_BYTES = 480 * 1024;
const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']);
/**
* Whether a path part names Git's metadata directory in any spelling Git itself refuses (read-cache.c, verify_path):
* any case; on NTFS with trailing dots or spaces and as the 8.3 short name `git~1`; on HFS with ignorable code points.
*/
export function isDotGit(part: string): boolean {
// NTFS ends a name at a stream separator (`:`), then drops trailing dots and spaces. Callers split on `\` as well
// as `/`, since NTFS reads a backslash as a directory separator.
const name = part.split(':')[0]!;
const plain = name.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, '').toLowerCase().replace(/[. ]+$/, '');
return plain === '.git' || plain === 'git~1';
}
/** Agent-controlled text in a finding is quoted (AGENTS.md), and each list is cut short, so a reason stays readable. */
const q = (text: string) => JSON.stringify(text.length > 300 ? `${text.slice(0, 300)}…` : text);
const list = (items: readonly string[]) => items.slice(0, 5).map(q).join(', ') + (items.length > 5 ? ` and ${items.length - 5} more` : '');
const TYPES = new Set(['file', 'symlink', 'gitlink', 'directory', 'other']);

/**
* Every field the audit reads, checked before it reads any (AGENTS.md: partial records fail closed). A missing boolean
* or entry type must never read as "clean". Returns the first problem, or null.
*/
function malformed(manifest: ChangeManifest): string | null {
if (!manifest || typeof manifest !== 'object') return 'The change report is missing.';
if (!Array.isArray(manifest.changes)) return 'The change report has no change list.';
for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const)
if (!Array.isArray(manifest[field]) || manifest[field].some(entry => typeof entry !== 'string')) return `The change report has no ${field} list.`;
if (typeof manifest.metadataChanged !== 'boolean') return 'The change report does not say whether Git metadata changed.';
let bytes = 0;
for (const change of manifest.changes) {
if (!change || typeof change !== 'object' || typeof change.path !== 'string' || !change.path) return 'A change has no path.';
// Only a rename has an old path; on any other kind it would be staged as if it were part of the change.
if (change.kind === 'rename' ? typeof change.oldPath !== 'string' || !change.oldPath : change.oldPath !== undefined) return `The change at ${q(change.path)} has an invalid old path.`;
if (!KINDS.has(change.kind)) return `The change at ${q(change.path)} has an unknown kind.`;
if (typeof change.underGit !== 'boolean') return `The change at ${q(change.path)} does not say whether it is under .git.`;
// add has only a new entry, delete only an old one, every other kind both.
const needsOld = change.kind !== 'add', needsNew = change.kind !== 'delete';
if ((needsOld && !TYPES.has(change.oldType as string)) || (!needsOld && change.oldType !== undefined)) return `The change at ${q(change.path)} has an invalid old entry type.`;
if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${q(change.path)} has an invalid new entry type.`;
if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${q(change.path)} has no target.`;
if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${q(change.path)} has an invalid traversal flag.`;
// Both paths can be saved (an undeclared rename source is a scope finding), measured as the JSON that stores them.
for (const path of [change.path, ...(change.oldPath ? [change.oldPath] : [])]) bytes += Buffer.byteLength(JSON.stringify(path)) + 1;
}
if (bytes > MAX_PATH_BYTES) return 'The change report is too large to audit.';
return null;
}

/** A stored link target must stay inside the repo, outside `.git`, without an absolute path. */
function unsafeLinkTarget(linkPath: string, target: string): string | null {
if (!target || target.includes('\0')) return 'empty or invalid target';
if (target.startsWith('/')) return 'absolute target';
const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target));
// A backslash or a drive prefix would be a separator or an absolute path on NTFS, where the audit's other checks
// (which use POSIX paths) could not see an escape: link text in this repository is POSIX-only.
if (target.includes('\\') || /^[A-Za-z]:/.test(target)) return 'target uses a Windows path form';
// Checked as written too: a `.git` part that a later `..` cancels (`.git/../src`) still names the metadata on the way.
if (target.split(/[/\\]/).some(isDotGit)) return 'target enters .git';
// A trailing slash names the same directory: `./` and `a/../` are the root, like `.`.
const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target)).replace(/\/+$/, '') || '.';
if (resolved === '..' || resolved.startsWith('../')) return 'target leaves the repository';
if (resolved === '.git' || resolved.startsWith('.git/')) return 'target enters .git';
// The repository root contains .git: a link to it reaches the metadata through one more path part.
if (resolved === '.') return 'target is the repository root';
// As for paths: Git's metadata is `.git` in any case, at any depth.
if (resolved.split(/[/\\]/).some(isDotGit)) return 'target enters .git';
return null;
}

Expand All @@ -50,31 +107,61 @@ function unsafeLinkTarget(linkPath: string, target: string): string | null {
*/
export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (path: string) => string): AuditOutcome {
const violations: string[] = [];
if (!Array.isArray(manifest.changes) || manifest.changes.length > MAX_CHANGES) return { kind: 'violation', violations: ['The change report is missing or too large to audit.'] };
if (!Array.isArray(manifest?.changes) || manifest.changes.length > MAX_CHANGES) return { kind: 'violation', violations: ['The change report is missing or too large to audit.'] };
const problem = malformed(manifest);
if (problem) return { kind: 'violation', violations: [problem] };
if (manifest.metadataChanged) violations.push('The agent changed Git metadata under .git.');
for (const path of manifest.linkTargetChanges) violations.push(`A declared symlink target changed: ${path}.`);
for (const path of manifest.nestedGitlinkContent) violations.push(`Content appeared under a gitlink: ${path}.`);
// Agents never commit: the metadata volume is read-only to them, so any agent commit is a violation, never undone (#66).
if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${list(manifest.agentCommits)}.`);
if (manifest.linkTargetChanges.length) violations.push(`A declared symlink target changed: ${list(manifest.linkTargetChanges)}.`);
if (manifest.nestedGitlinkContent.length) violations.push(`Content appeared under a gitlink: ${list(manifest.nestedGitlinkContent)}.`);
const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey));
// Each path appears once, under the trusted path identity: two entries for one path contradict each other.
const seen = new Map<string, ManifestChange[]>();
for (const change of manifest.changes) {
// A case-only rename's two sides are one path under a folding identity; count it once for this change.
const keys = new Set([change.path, ...(change.oldPath ? [change.oldPath] : [])].map(pathKey));
for (const key of keys) {
const entries = [...(seen.get(key) ?? []), change];
seen.set(key, entries);
if (entries.length === 1) continue;
// The only second entry allowed is a case-only rename that Git reports as one delete and one add (the file also
// changed a lot): exactly two entries, one delete and one add, with different spellings.
const [a, b] = entries as [ManifestChange, ManifestChange];
const splitRename = entries.length === 2 && a.path !== b.path && [a.kind, b.kind].sort().join() === 'add,delete';
if (!splitRename) return { kind: 'violation', violations: [`The change report lists ${q(key)} more than once.`] };
}
}
for (const change of manifest.changes) {
const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])];
if (change.underGit || paths.some(path => path === '.git' || path.startsWith('.git/'))) { violations.push(`The agent changed ${change.path} under .git.`); continue; }
if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || path.includes('\0')))
{ violations.push(`Invalid path in the change report: ${change.path}.`); continue; }
if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${change.path}.`); continue; }
// A path must be in canonical form: another spelling (./, a/../, //, a trailing /) could reach .git or hide a match.
if (paths.some(path => path !== posix.normalize(path) || path.endsWith('/') || path.startsWith('./')))
{ violations.push(`Path not in canonical form in the change report: ${q(change.path)}.`); continue; }
// Git refuses a .git part in any spelling it treats as .git, at any depth, so the audit does too.
if (change.underGit || paths.some(path => path.split(/[/\\]/).some(isDotGit))) { violations.push(`The agent changed ${q(change.path)} under .git.`); continue; }
if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0')))
{ violations.push(`Invalid path in the change report: ${q(change.path)}.`); continue; }
if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${q(change.path)}.`); continue; }
// Only a declared pre-existing link may change at all: deleting it, turning it into a file, or renaming it from an
// undeclared path is a link change too.
if (change.oldType === 'symlink' && !declared.has(pathKey(change.oldPath ?? change.path)))
{ violations.push(`A pre-existing symlink was changed at an undeclared path: ${q(change.oldPath ?? change.path)}.`); continue; }
if (change.newType === 'symlink') {
if (change.oldType !== 'symlink') { violations.push(`New symlink or file-to-symlink conversion: ${change.path}.`); continue; }
if (!declared.has(pathKey(change.path))) { violations.push(`A pre-existing symlink changed at an undeclared path: ${change.path}.`); continue; }
if (change.oldType !== 'symlink') { violations.push(`New symlink or file-to-symlink conversion: ${q(change.path)}.`); continue; }
if (!declared.has(pathKey(change.path))) { violations.push(`A pre-existing symlink changed at an undeclared path: ${q(change.path)}.`); continue; }
const unsafe = unsafeLinkTarget(change.path, change.newLinkTarget ?? '');
if (unsafe) { violations.push(`Unsafe symlink target at ${change.path}: ${unsafe}.`); continue; }
if (change.linkTargetTraversesLink !== false) { violations.push(`The symlink target at ${change.path} traverses another link, or was not checked.`); continue; }
if (unsafe) { violations.push(`Unsafe symlink target at ${q(change.path)}: ${unsafe}.`); continue; }
if (change.linkTargetTraversesLink !== false) { violations.push(`The symlink target at ${q(change.path)} traverses another link, or was not checked.`); continue; }
}
for (const type of [change.oldType, change.newType]) if (type === 'directory' || type === 'other') violations.push(`Unexpected ${type} entry: ${change.path}.`);
for (const type of [change.oldType, change.newType]) if (type === 'directory' || type === 'other') violations.push(`Unexpected ${type} entry: ${q(change.path)}.`);
}
if (violations.length) return { kind: 'violation', violations };
const inScope: string[] = [], outOfScope: string[] = [];
for (const change of manifest.changes) {
const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])];
(paths.every(path => declared.has(pathKey(path))) ? inScope : outOfScope).push(change.path);
const undeclared = paths.filter(path => !declared.has(pathKey(path)));
// Each undeclared path is the scope finding itself, a rename's source included: never only the declared other side.
if (undeclared.length) outOfScope.push(...undeclared); else inScope.push(change.path);
}
return { kind: 'commit', inScope, outOfScope, unchanged: manifest.changes.length === 0, needsAmendment: outOfScope.length > 0 };
}
2 changes: 1 addition & 1 deletion docs/implementation/runner-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ This runs before the coordinator opens.

## HTTP and UI contract

**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" }`. The UI shows "Needs restart: result could not be saved" only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review.
**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" | "preparation-not-removed" | "storage-not-removed" }`: the terminal write or the start could not be saved; the host-side preparation files could not be removed (before the terminal write when D never ran, after it once D settled); or the task storage could not be removed after a saved terminal write. The UI shows "Needs restart" with that cause only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review.

**User actions.** Cancel, retry and "run again" requests send `attemptId`, `expectedStateVersion` and an `actionId` idempotency key (see "Feedback-event contract"). A replayed `actionId` returns the saved outcome. The server takes the plan identity from its trusted configuration, never from the request, and every `Store` call is scoped by that identity. A mismatch returns HTTP 409 with the current state. The UI then shows that state and keeps any draft.

Expand Down
Loading
Loading