diff --git a/docs/agent-harness-progress.html b/docs/agent-harness-progress.html index 74e2ffa..d9f6035 100644 --- a/docs/agent-harness-progress.html +++ b/docs/agent-harness-progress.html @@ -182,5 +182,119 @@
typecheck excludes test files (tsconfig.json exclude), so a fake that drifts from an interface — e.g. a Db stub missing updateModel — is caught only at runtime. Two fakes needed manual updating this loop for exactly that reason.One file: .build/agents/hyper/user.md, global scope, injected at the top of every
+turn ahead of the skills manifest. It carries the things a user would otherwise retype — "I use
+Tailwind", "keep copy lowercase".
The design point is the asymmetry. A persona is injected as trusted
+guidance; the skills manifest, three feet away in the same prompt, is injected as
+untrusted data. That is defensible for exactly one reason: the agent cannot write
+a persona. .build/agents/** is refused by fs_write,
+fs_batch_write and fs_delete while staying open to fs_read.
+A skill file is agent-writable, which is why a prompt injection reaching one tool call
+could plant one and have it re-read with authority every turn afterwards — a one-shot compromise
+made durable. If that write ban is ever lifted, the trusted framing must go with it; the two facts
+move together and there are tests on both halves.
| Check | Evidence |
|---|---|
| Persona reaches the prompt, ahead of skills | Browser probe: personaInPrompt, personaBeforeSkills both true; parity test pins the order on both client and server builders |
| Write ban holds through the real executor stack | Browser probe: fs_write and fs_delete refused, fs_read allowed, file intact after the attempt |
| Persona actually steers the model | Live run, PERSONA_MARKER: 4/5 files the agent wrote carried the required header line. The miss is style.css, where // is not a valid CSS comment — correct judgment, not disobedience. |
Seeding a persona was incidental to the real find. The new refusal log surfaced
+exec: [exit 1] followed by tsc's help text — and reproducing it outside the browser was
+decisive:
tsconfig.json. With no config and
+no file arguments, tsc does not typecheck at all: it prints its usage banner and exits
+1. npx tsc --noEmit is the headline command of the built-in verify skill,
+so every turn that tried to verify burned a step on a command that could not succeed.@types/react / @types/react-dom, so even with a
+config a real error drowns under TS7016/TS7026 noise about React having no declarations.typescript: "latest" had silently resolved to TypeScript 7.0.2, the
+native rewrite — a major version change under existing projects, in the one dependency this template
+left unpinned while carefully pinning everything else.Fixed on both duplicated template surfaces (the drift guard caught the Gleam side, as designed):
+a tsconfig.json with types: ["vite/client"] so side-effect CSS imports are
+not reported as TS2882, React type declarations, and typescript pinned to
+^5.7.2. tsconfig.json joins UNDELETABLE — deleting it does not
+break the app, it breaks the agent's ability to check the app, which is worse because the
+loop keeps reporting that it verified.
Measured before/after on the same live turn:
+| Steps | Provider calls | Wall | Trail says | |
|---|---|---|---|---|
| Before | 8 | 7 | 100s | "Checked the code — found problems" |
| After | 5 | 4 | 58s | "Checked the code — no problems" |
Verification now genuinely runs, and the turn is shorter because the agent is no longer fighting a
+command that structurally could not work. Three regression tests in
+templates.test.ts pin the reason each of the three pieces exists, so a future cleanup
+does not read them as boilerplate.
The trail showed Tried to write files it may not change and nothing else — no file,
+no reason — so a live run could only guess at what a guard had blocked. executeTool now
+ring-buffers failed tool results (failures only, reason truncated to 300 chars, capped at 50) and the
+live suite prints them. That is what turned an unexplained exit 1 into the tsconfig
+finding above.
A judge pass against the branch confirmed the write ban held (24 adversarial paths: no-slash,
+trailing slash, whitespace, .. re-entry, double slash, backslash, leading /,
+leading ./, sibling-prefix escape, and the batch form hiding the path among legitimate
+files — all refused, and the batch stayed all-or-nothing). It also found real problems.
| Finding | Resolution |
|---|---|
Blocker: nothing could author a persona. The agent was banned from writing
+.build/agents/ and no other writer existed — no editor, no settings surface. In
+production readPersona() always returned ''. The whole slice was prompt
+plumbing for a file with no author. |
+Built the editor: a Standing-instructions field in the settings panel, in both the BYOK +and managed panels, with its own save (the managed panel has no Save button). It is deliberately the +only writer, which is precisely what makes the trusted framing legitimate. |
Two new guard tests passed vacuously. indexOf returns
+-1 when the injection is deleted, and String.slice(-1) then yields one
+character — so the "stays trusted" test passed with the injection removed. It also matched lowercase
+untrusted only, so a capitalized re-framing slipped through. The ordering test could be
+defeated by hoisting const p = body.persona above the pushes. |
+Both rewritten to call the two builders and inspect real output. Mutation-tested: deletion, +capitalized re-framing, and hoist-and-reorder now each fail. |
The server did not own the block it treats as trusted. The client sent a
+pre-framed system message; the server validated only typeof === 'string'. A modified
+client could post its own trusted framing, or megabytes of it. |
+The client now sends RAW text. Both prompt builders author and cap the block themselves — a third +duplicated surface, guarded like SHARED_RULES, including a test that the cap applies on both sides. |
| The tsconfig fix reached new projects only. Every project created before it +kept the broken verify loop. | +ensureVerifiable() backfills tsconfig.json and the React types on project load —
+additive, never repinning typescript (that would force a reinstall on open), and
+returning the same array when nothing is missing so an untouched project is not re-saved. |
The Ollama JSON path bypassed every write guard, applying model-supplied
+paths straight to the project actor. It could not reach the workspace store, but it could put a file
+at the literal persona path into project.files, which publish ships verbatim. |
+Routed through the same path policy, with refusals surfaced rather than silent. .build
+added to IGNORED_SYNC_DIRS so a container-side .build/ cannot sync back into
+one path with two contents. |
The persona ban was case-sensitive while DENIED_EXACT twenty lines above carries
+a comment about that exact asymmetry having been an oversight once. |
+Made case-insensitive. Match the strict half, not the lenient one. |
| The failed-tool log survived project switches, so one project's failure text was readable +while working in another. | Cleared on project open. |
Found while fixing, and missed by the review: three call sites read
+starterFiles[2].path — "the third starter file", which meant src/main.tsx
+only by accident of ordering. Inserting tsconfig.json silently retargeted all three to
+index.html, changing which file the editor opens on. Replaced with a lookup by name.
The editor was then verified in a real browser against nine conditions — visible on first run,
+enables on edit, disables after save, the agent reads exactly what was typed, it reaches the prompt
+framed as guidance, the agent still cannot overwrite it, it survives reload, and clearing the box
+removes the file rather than leaving an empty one. The reload check failed first time
+and found a real bug: the panel starts open, so no SettingsOpened message is ever sent on
+a fresh load and the load effect never fired — standing instructions would have looked lost on every
+reload.