v0.3.0: memória entre sessões, adapter claude-code, e os cinco achados do primeiro teste de campo - #3
Merged
Merged
Conversation
… no projeto Medido contra o ai-jail 1.19.2, ao investigar se a marca de qual agente está executando sobrevive dentro da jaula. Por padrão o ai-jail grava um `.ai-jail` na raiz do projeto e o lê na execução seguinte. O arquivo mora dentro da árvore que o agente edita, então parte da montagem passaria a vir de algo que o próprio enjaulado escreve, que é o G4 que o PRD aponta no harness de referência. Na prática ele também acumulava lixo: cada corrida somava os deny paths de novo, guardados na forma `~/...`, e o ai-jail os reabria como `<raiz>/~/...`, avisando "rule not applied" para regra que não existia. A regra que valia continuou sendo a do argv, e isso está medido, mas o ruído escondia o aviso de verdade. O argv passou a levar `--clean --no-save-config`. Com a escrita desligada, a exceção que perdoava o `.ai-jail` no relatório de violação perdeu o motivo de existir e só servia para deixar passar uma cópia feita pelo agente. O conjunto de arquivos perdoados ficou vazio e o arquivo entra no deny como qualquer outro. A terceira coisa é que a jaula zera o ambiente do processo filho: `PSH_AGENT` no env do spawn chega vazio lá dentro. A marca vai explícita no argv, por `--env`, para que o núcleo saiba quem pediu a ação também dentro da jaula. Quatro casos novos na suíte de integração com o binário real: nenhuma configuração deixada no projeto, a terceira corrida enjaula igual à primeira, configuração plantada na raiz não muda a fronteira, e o id do agente atravessa a jaula.
Primeira metade do C5, a faixa transitória de conhecimento entre sessões. A página de memória é markdown em `.harness/memory/pages/`, com cabeçalho validado por JSON Schema. O arquivo é a versão canônica e o SQLite é só índice, que pode ser apagado e reconstruído sem perda: a mesma divisão da evidência. Toda busca sincroniza o índice antes de responder, comparando o hash do arquivo. Página editada fora do psh entra na resposta seguinte, página corrompida sai do índice com aviso, e o número de páginas examinadas vai na saída (R2.13). Sem FTS5 no SQLite a busca cai para varredura e diz que caiu, no resultado e no `psh doctor`. O `psh handoff` monta o bloco de retomada a partir do estado e da evidência em disco, nunca de resumo de modelo: fase, tentativa, última decisão, o que reprova o portão agora, memória fixada e o próximo comando. É o UC3, fechar a sessão por limite de plano e reabrir depois, possivelmente em outro runtime. A memória entra no deny duro da fronteira, e isso está medido contra o ai-jail real. O bloco de handoff é injetado no início da sessão seguinte, então memória que o agente escreve à mão é texto que ele injeta em si mesmo depois, sem passar por nenhuma porta do núcleo. Falta para fechar a v0.3: a captura automática (R5.1) depende dos hooks do adapter claude-code, e a consolidação por LLM (R5.2) depende do Maestro. Três coisas que a revisão achou e que não são da memória: - Quebra de linha em título ou origem gravava a página, reportava sucesso e deixava a anotação ilegível para sempre. Tag com vírgula voltava partida em duas. As duas param na porta de escrita, com o campo nomeado. - `psh memory promote --to` deixaria um agente escrever fora da própria allowlist com a assinatura do núcleo. O destino passa pela fronteira de quem promove. - Flag de traço simples nunca existiu no parser: `psh audit log -n 5` virava dois posicionais ignorados e o comando respondia com o limite padrão, sem erro. Agora falha dizendo a forma certa, e `--n abc` não chega mais ao LIMIT do SQLite como NaN.
R5.2, a segunda metade do C5: a sessão vira uma página de memória. A captura já existia e ninguém tinha reparado. Decisão de fase, resultado de verificador, violação de fronteira, decisão humana e anotação entram na trilha desde o R4.3, encadeadas por hash e conferíveis por `psh audit verify`. Guardar uma segunda cópia dos mesmos fatos num buffer paralelo criaria duas versões da mesma sessão, e a segunda não teria como provar que é verdadeira. Então a consolidação lê a trilha, e cada linha da página carrega o número da entrada que a originou. O que R5.2 pede e esta versão não faz é a reescrita como narrativa, porque isso é chamada de modelo e precisa passar pelo Maestro, que é v0.4. Chamar modelo por fora do roteador seria furar a própria regra. A página diz, nela mesma, que foi montada sem modelo. Quando o C6 entrar, a narrativa vira uma reescrita por cima deste texto. A marca d'água mora em `.harness/memory/consolidation.json`, não no SQLite: o índice é descartável por construção, e se a marca morasse nele, jogar o índice fora faria a próxima consolidação varrer a trilha inteira e despejar meses de histórico numa página só. Ela também só avança depois de a página existir e a trilha registrar, porque na ordem inversa uma falha no meio perderia a sessão para sempre. Duas coisas que a primeira execução mostrou: - A consolidação se auto-alimentava. Ela grava uma entrada `memory.write` ao terminar, e essa entrada virava material da corrida seguinte: rodar o comando três vezes produzia três páginas, e as duas últimas só falavam da anterior. Agora ela pula as próprias entradas, e a marca d'água avança mesmo quando não há página, senão o bookkeeping ficaria sendo relido para sempre. - Trilha comprometida não vira memória. A cadeia é verificada antes de resumir, porque assinar como memória um relato que a própria cadeia não sustenta seria fabricar prova. A página de sessão nasce solta, não fixada: o que precisa sobreviver a qualquer corte é o que o humano fixou com `psh remember` (R5.5), e um resumo não entra nessa categoria só por ser recente. Ela aparece em "Memória recente" no `psh handoff` seguinte, que é a seção que até agora nunca enchia. Tipo de entrada que esta versão não sabe resumir sai contado por tipo na própria página (R2.13). Entrada que some sem número vira sessão que parece menor do que foi.
A âncora existe para pegar reescrita coordenada da cadeia (R4.1), mas quem a conferia era só o `psh audit verify`. Como todo `append` regrava a âncora com o topo novo, bastava uma escrita qualquer depois da adulteração para a cadeia voltar a fechar: remova a última linha da trilha, rode `psh remember`, e o `verify` volta a dizer "íntegra" para uma trilha de onde uma entrada foi arrancada. Agora o `append` confere antes de escrever e recusa com erro de auditoria. A conferência é por contagem de linha e hash do topo, sem re-hashear a cadeia inteira: remoção no meio muda a contagem, e edição ou religamento mudam o hash do topo. É o mesmo alcance que a âncora já prometia, cobrado antes da escrita e não só depois dela. Achado por um teste do adapter que esperava a consolidação recusar uma trilha quebrada e viu ela passar, porque o próprio hook de fim de sessão tinha gravado uma entrada antes e consertado a âncora no caminho.
C8 para o runtime que a casa usa, e a peça que fechava o R5.1: o prompt do usuário, que é a única coisa da captura que a trilha não vê sozinha. Cinco pontos de extensão. SessionStart injeta as regras do harness e o bloco de retomada; UserPromptSubmit registra o pedido; PreToolUse aplica a fronteira antes da escrita acontecer; PostToolUse avisa quando a escrita derrubou o frescor de uma evidência; SessionEnd consolida a sessão. O registro é em forma exec: o runtime executa o binário direto, com os argumentos em lista, sem shell no meio. Caminho de instalação com aspas, cifrão ou crase nunca chega a um parser de shell, que é a mesma postura do R2.14 para expressão regular. Nada disso foi escrito de memória. O R8.6b manda validar contra o runtime instalado, e a seção 3.2 do PRD registra duas vezes em que essa regra foi quebrada nesta casa: 881 linhas de plugin morto no harness de referência, e os três mecanismos do prostaff-hooks que nunca responderam porque o campo se chamava `prompt` e o código dizia `message`. Então os nomes saíram do artefato do Claude Code 2.1.238, ficam em `contract.json`, e `psh adapter claude-code contract` lê o binário e reprova o que não existe. O que o binário mostrou e a documentação não diria: o runtime aceita 31 eventos de hook, `PreToolUse` tem `defer` além de allow, deny e ask, a entrada de hook aceita a forma exec, e `SessionEnd` existe. Este último mudou o desenho: `Stop` dispara ao fim de cada turno, e consolidar ali geraria uma página por resposta do modelo. A validação foi conferida numa sessão real, pelo ponto de entrada do usuário e sem gastar turno de modelo (R8.6d). O log do próprio runtime registra "Successfully parsed and validated hook JSON output" e "provided additionalContext (1253 chars)", e o efeito foi conferido em disco (R8.6e): as entradas na trilha e a página de sessão gerada. Falha do adapter nunca vira sessão travada nem escrita liberada em silêncio: em PreToolUse a decisão degradada é `ask`, com o motivo na tela, e quem decide passa a ser o humano. A poda é de duas pontas e por handler (R8.6f): instalar remove o registro antigo do psh, preserva hook de terceiro no mesmo evento, e conta quantos apontavam para artefato ausente. `psh doctor` diz quantos pontos estão ativos, porque quatro de cinco significa uma responsabilidade do R8.1 que simplesmente não acontece. Fica declarado o que não entrou: servidor MCP com as tools do núcleo (R5.6), compressor de saída (R8.7), e o registro de toda chamada de tool na trilha, que somaria um fsync por chamada e precisa de medição antes de virar padrão. Também fica declarado o limite da varredura de símbolo: ela procura bytes, então símbolo que é prefixo de outro é dado como presente quando só o mais longo existe.
Memória entre sessões e adapter Claude Code, os dois marcos que o roadmap declarava para esta versão. Fica registrado no README o que a versão não faz: a página de sessão é montada da trilha e não escrita por modelo, a memória não é fonte canônica, o adapter não expõe tools por MCP, e a atribuição de quem fez o quê é melhor esforço dentro da jaula.
Achado medindo o harness contra um projeto real, com o binário compilado. `psh --version` levava 185 ms; o `bun` cru leva 2 ms. Os 181 ms de diferença iam embora antes de a primeira linha de lógica rodar, porque o ajv compilava os sete schemas do projeto no escopo de módulo, mesmo em comando que não valida nada. Isso não era só cosmético: o hook do adapter roda uma vez por chamada de tool, então essa partida entrava no caminho crítico de cada escrita da sessão. Os validadores passaram a compilar sob demanda, mantendo a interface do ajv, inclusive `.errors`. Nenhum ponto de uso mudou. Depois: `psh --version` em 53 ms e o hook de PreToolUse em 156 ms. O que sobra está atribuído e registrado no CHANGELOG: a decisão de fronteira leva 1 ms, o `loadBoundary` leva 38 ms compilando o schema da allowlist sob demanda, e o resto é partida do binário. Baixar mais exige validador pré-compilado em tempo de build.
O `buildArgv` não passava `--clean` nem `--no-save-config`, então o ai-jail gravava a configuração da corrida no `.ai-jail` do projeto e a lia na corrida seguinte. Config de projeto do ai-jail é política monotônica: ela só aperta, e a linha de comando não reabre o que ela fechou. Ou seja, o que o psh gravava numa corrida só podia tirar capacidade da corrida seguinte, nunca devolver. Um verificador com `network: true` rodava sem rede logo depois de um sem rede. O curl saía com 6, o promptfoo contabilizava oito erros de conexão e ainda assim produzia relatório com `successes: 0`, o psh extraía esse zero e o portão decidia em cima dele. Métrica de corrida cujo contrato de sandbox não foi cumprido, e nada na saída dizia isso. O `psh exec` já passava as duas flags. Os dois pontos de entrada da jaula discordavam entre si, e o errado era justamente o que produz valor de portão. A mesma escrita sujava a árvore observada durante a corrida: qualquer verificador com `watch: ["**"]` reprovava por `workspace-mutated-during-run`, incluindo o `secrets` que vem no pacote. Um verificador que roda `/bin/true` reprovava. A invariante que isso cristaliza: sandbox(corrida atual) não depende de sandbox(corrida anterior). Cinco testes de integração cobrem as duas ordens de execução com rede real, e foram exercitados contra o próprio defeito: com a correção revertida, os cinco falham. Achados 1 e 2 do Campo 01.
…vore Dois defeitos do mesmo mecanismo, achados no primeiro teste de campo. O artefato de runtime do próprio harness contava como mudança do workspace. A lista de sempre-excluídos cobria quatro caminhos enquanto o `Layout` já tinha sete diretórios de runtime, então `memory/`, `approvals/`, `reviews/` e `tmp/` entravam no hash. Um `psh memory consolidate` entre a medição e o portão derrubava a evidência de quem observa `**`, citando arquivo que verificador nenhum escreveu. Num projeto Git o `.harness/.gitignore` mascarava parte disso. A classificação agora é explícita nos dois sentidos, em `HARNESS_RUNTIME_PATHS` e `HARNESS_OBSERVABLE_PATHS`: contrato e documento de fase moram no mesmo diretório e continuam observáveis, porque escondê-los abriria a classe dos arquivos invisíveis ao observador. Há teste cobrando que todo caminho do `Layout` esteja numa das duas listas, porque o defeito não foi a lista estar errada, foi ela ter envelhecido calada enquanto o `Layout` crescia. Pelo mesmo motivo o `tmpDir` entrou no `Layout`, em vez de continuar como a string "tmp" repetida no `execute.ts`, que era como ele escapava da classificação. A exclusão passou a aparecer no manifesto da evidência, contada e nomeada: decisão de medição que não fica no registro não pode ser auditada depois. O segundo: `isGitRepo` era `existsSync(join(root, ".git"))`, que só acerta o projeto que é a raiz do repositório. Todo app dentro de um repositório maior caía para caminhada, e o `.gitignore` deixava de valer para o hash. Na cobaia entraram no hash o `.env` com chave real, o diretório de relatório e um binário de node de 100 MB. O `psh doctor` chamava isso de `[ok] enumeracao por caminhada`, com o detalhe "projeto nao e repositorio Git", que é falso, enquanto o `checkSecrets` do mesmo doctor usava `git ls-files` no mesmo diretório sem problema. Quem responde agora é o Git, por `git rev-parse --is-inside-work-tree`. O fallback pelo diretório continua para um caso só, o `git` não ter respondido, porque é ele que mantém o `walk-fallback` visível em vez de virar caminhada silenciosa. A regra que os dois casos compartilham: a infraestrutura usada para observar um workspace não pode modificar o conjunto de arquivos observado. Resíduo do achado 2, e achado 4 do Campo 01.
A recusa de escrever numa trilha que não bate com a âncora funciona e está certa, mas ela travava o projeto para qualquer operação que escreva na trilha, ou seja praticamente todas. A mensagem mandava rodar `psh audit verify`, que só confirma o diagnóstico, e nenhum comando resolvia. Sobrava mexer no `.harness` na mão, que é exatamente como evidência de adulteração desaparece. Reancorar não conserta a trilha nem finge que a divergência não houve: grava na própria trilha, como `audit.note`, o que a âncora dizia, o que o arquivo diz, quem decidiu e por quê, e só então move a âncora para o topo real. A divergência vira cicatriz permanente e legível, em vez de virar diretório apagado no susto. O `--reason` é obrigatório por natureza do comando: o valor dele está no que fica escrito, não em silenciar o alarme. E ele recusa quando o defeito está dentro do arquivo, ou seja linha corrompida, seq fora de ordem, elo quebrado ou hash que não fecha. Aí a âncora não é o defeito, e mover a âncora só trocaria um relatório vermelho por outro. Prometer conserto nesse caso seria pior que não ter o comando. A mensagem da recusa passou a citar a saída, em vez de só dizer que travou. Achado 10 do Campo 01.
`boundary_engine` era o literal "absent" no tipo e no objeto, declaração da v0.1
de quando o C3 não existia, que ninguém atualizou quando o motor de fronteira
entrou na v0.2.
No mesmo projeto e no mesmo instante o `psh status` dizia `fronteira mount` e o
`psh doctor` dizia `fronteira aplicada por mount do ai-jail`, enquanto o JSON de
CI dizia que motor de fronteira não havia. Quem consome esse JSON conclui o
contrário do que está acontecendo, que é a pior falha possível num contrato de
máquina: não é ausência de informação, é informação invertida.
O campo virou `boundary: { mode, agents, detail }` e sai da `descreverFronteira`,
a mesma função que alimenta o `psh status`.
O defeito não foi o valor errado, foi haver duas declarações paralelas do mesmo
fato, e por isso o teste não confere o valor: ele exige que `ci --json` e
`status --json` digam a mesma coisa, palavra por palavra.
Muda o formato do relatório: quem consumia `boundary_engine` passa a ler
`boundary.mode`.
Achado 3 do Campo 01.
A 0.3.0 foi escrita em 23/08 e nunca chegou a ser publicada. Entre escrever e publicar ela foi a campo pela primeira vez, contra um projeto real que chama LLM de verdade, e voltou com dez achados. Cinco foram corrigidos antes desta publicação e estão nos commits anteriores. Os outros cinco ficam declarados no laudo e no README: nenhum deles produz valor de portão errado, que foi o critério para não segurar a versão. O bloco [Não lançado] do changelog foi absorvido na 0.3.0, que passa a ser datada de 25/08. O README do psh ainda abria com "Marco v0.1" e tinha uma seção dizendo que o C3 entrava na v0.2 e que o adapter `ci` devolvia `boundary_engine: absent`. Era a mesma classe de mentira do achado 3, só que no arquivo que se lê primeiro. O marco virou v0.3, a seção "Fora do escopo" foi reescrita para o que de fato falta, e o layout em disco passou a mostrar `memory/` e `tmp/`, que existem desde a v0.3 e não estavam lá. O README da raiz tinha o mesmo `boundary_engine: absent` no exemplo de JSON do CI. A seção "O que o psh não faz" ganhou os achados que seguem abertos, incluindo o do toolchain e credencial, que no campo custou vendorizar o binário do node dentro da árvore. Também entram no changelog as três decisões de projeto que os achados cristalizaram: quem observa um workspace não o modifica, trilha divergente tem caminho de volta declarado, e um fato tem uma fonte só.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A v0.3.0 foi escrita em 23/08 e nunca chegou a ser publicada. Entre escrever e
publicar, ela foi a campo pela primeira vez: um projeto real que chama LLM de
verdade, com ai-jail 1.20.1, verificador gastando API da Anthropic e da OpenAI, e
uma sessão real do Claude Code sob os cinco hooks.
Tudo o que o marco promete se sustentou, e voltaram dez achados. Cinco estão
corrigidos aqui. Os outros cinco ficam declarados no laudo e no README, porque
nenhum deles produz valor de portão errado - foi esse o critério para não segurar
a versão.
O laudo completo está em
DEVDOCS/CAMPO-01-multilingo.md, e o roteiro quereproduz tudo no terminal em
.verificacao/campo-multilingo/run.sh.O que vem nos commits
A jaula de uma corrida não contamina a corrida seguinte (achados 1 e 2).
buildArgvnão passava--clean --no-save-config, então o ai-jail gravava aconfiguração da corrida no
.ai-jaildo projeto e a lia na corrida seguinte.Config de projeto do ai-jail é política monotônica: ela só aperta, e a linha de
comando não reabre o que ela fechou.
Um verificador com
network: truerodava sem rede logo depois de um sem rede. Opromptfoo contabilizava oito erros de conexão e ainda assim produzia relatório
com
successes: 0, o psh extraía esse zero e o portão decidia em cima dele.Métrica de corrida cujo contrato de sandbox não foi cumprido, sem nada na saída
dizendo isso.
O
psh execjá passava as duas flags: os dois pontos de entrada da jauladiscordavam entre si, e o errado era justamente o que produz valor de portão.
O frescor volta a medir a árvore (resíduo do achado 2, e achado 4).
O artefato de runtime do próprio harness contava como mudança do workspace: um
psh memory consolidateentre a medição e o portão derrubava a evidência de quemobserva
**, citando arquivo que verificador nenhum escreveu.E
isGitRepoeraexistsSync(join(root, ".git")), então todo app dentro de umrepositório maior caía para caminhada e o
.gitignoredeixava de valer para ohash. Na cobaia entraram no hash o
.envcom chave real e um binário de node de100 MB.
psh audit reanchor --reason "..."(achado 10).A recusa de escrever em trilha que não bate com a âncora travava o projeto sem
saída, e a única alternativa real era apagar o
.harnessna mão - que é comoevidência de adulteração desaparece.
O relatório de CI diz o estado real da fronteira (achado 3).
boundary_engineera o literal"absent"desde a v0.1. Quem consome o JSON no CIconcluía o contrário do que estava acontecendo.
A regra que os achados cristalizaram
A infraestrutura usada para observar um workspace não pode modificar o conjunto
de arquivos que está sendo observado. Em termos do harness: executar verificador
não é mutar workspace, e
sandbox(corrida atual)não depende desandbox(corrida anterior).Verificação
524 testes, zero falhas e zero pulados, rodando com
ai-jailreal e com rede.Cobertura de linha em 98,43%.
Os testes de integração da jaula ficavam sempre em
skipnesta máquina porque oAppArmor do
buninstalado por snap negaexecde binário de fora doconfinamento. Com um
bunfora do snap eles rodam, e é a primeira vez que asuíte fecha inteira.
Cada correção vinda do campo foi exercitada contra o próprio defeito antes de
valer como regressão: com a correção revertida, os testes falham. Sem isso um
teste de regressão é só um teste a mais.
Quebra de contrato
Quem consumia
boundary_engineno JSON do adaptercipassa a lerboundary.mode.