Modern Agent Engineering

Construye tu propio bot autónomo: copia los controles, no el loop del modelo

Cuatro agentes autónomos se diferencian menos por el modelo que por la ubicación de cada control. Una implementación en seis pasos para un repo: admisión en modo sombra, un sandbox sin credenciales, acciones revisadas y verificaciones en el sandbox y en el push.

53 min de lectura
Construye tu propio bot autónomo: copia los controles, no el loop del modelo

Entre el 3 de septiembre y el 31 de octubre de 2026 se presentaron públicamente cuatro agentes autónomos: Grok Bot de xAI, dots de OpenAI, Claude Duet de Anthropic y dsh-superagent, un prototipo de investigación del equipo DeepSeek Harness. Cada anuncio combina un loop de modelo, una UI y un conjunto de controles. Si quieres uno para tu propio repo, el loop es la parte fácil de copiar. Los controles son donde los cuatro se diferencian y donde una copia puede omitir alguno sin que se note.

Este artículo los examina, junto con Copilot cloud agent de GitHub como referencia para agentes vinculados a un repo, a partir de una pregunta: ¿cada control está dentro o fuera del alcance del agente? Después construye la versión más pequeña para un repo de GitHub en seis pasos, con verificaciones en dos límites. Ejecutamos todo lo que puede correr en una laptop. Las partes que llaman a GitHub siguen su documentación y solo se ejecutaron contra respuestas predefinidas y sustitutos locales. Todas las afirmaciones sobre los productos provienen de sus proveedores y corresponden al 2 de noviembre de 2026; no probamos ninguno de ellos.

TL;DR

  • La implementación de abajo sigue una misma estructura: entran eventos, se decide quién puede iniciar trabajo, se trabaja de forma aislada y se verifica antes de enviar acciones. Los productos se ajustan a ella de manera desigual y difieren sobre todo en dónde ubican cada control. dots y Duet dicen que sus verificaciones están fuera del modelo o de su entorno. Las páginas de Grok Bot que leímos no dicen dónde se ejecuta Auto Review. No encontramos un revisor independiente en dsh-superagent. La documentación de GitHub dice que los límites de Copilot cloud agent los impone el propio GitHub: una rama, sin merge y workflows retenidos hasta que una persona los apruebe.
  • Orden de implementación: define un presupuesto de atención, ejecuta el gate (el código que decide quién puede iniciar trabajo) en modo sombra, asigna a cada tarea un sandbox sin credenciales, publica solo trabajo verificado desde fuera del sandbox, comprueba dos límites y luego activa una regla sencilla.
  • La llamada con privilegios le corresponde a un publicador: código del plano de control que hace push y abre el PR con un token que solo él posee. No encontramos cómo limitar un token a una rama, y el token que hace push también puede llamar al endpoint de merge. Las revisiones obligatorias en la rama predeterminada son el control en el que debes apoyarte [INFERENCIA: ninguna frase de la documentación que leímos dice que bloqueen esa llamada].
  • Una prueba dentro del sandbox no puede ver la segunda ruta. El código que el bot hace push se ejecuta después en CI y previews, y los workflows del mismo repo pueden leer los secretos que referencian [INFERENCIA]. GitHub documenta la retención de ejecuciones de workflows para Copilot y para PR abiertos con GITHUB_TOKEN. No encontramos lo mismo para el token de una App personalizada, y una nota del propio GitHub señala el token de una App como forma de evitar la retención. Por eso, mantén los jobs activados por ramas libres de credenciales.
  • No probamos los productos, llamadas reales a GitHub, un kernel hostil ni si un revisor cansado aprobaría un PR malicioso pero convincente. Una lista cerca del final enumera lo que debes resolver antes de activar auto.

Cuatro agentes y una referencia vinculada a un repo

Grok Bot, dots y Duet son agentes personales con su propia computadora en la nube o VM. Al 2 de noviembre de 2026, dots está desplegándose en los planes Pro, Business Premium y Enterprise en los mercados habilitados, y Duet es una vista previa de investigación con lista de espera para suscriptores de Pro, Max y Ultra en Estados Unidos, sin incluir Team ni Enterprise. dsh-superagent es un agente de equipo para un solo repo que se ejecuta en la máquina del operador, como el usuario del sistema operativo del operador. Copilot cloud agent de GitHub (las URL de documentación antiguas y algunas actuales todavía dicen coding-agent) es un agente para repos que se ejecuta en un sandbox en la nube, un runner de Actions o un entorno personalizado. Para un bot vinculado a un repo, es la referencia disponible más cercana, así que aparece junto a dsh-superagent en la segunda tabla.

Páginas consultadas el 2 de noviembre de 2026; los niveles de los planes y la aplicación de los controles cambian con frecuencia (la página de aprobaciones de Grok Bot ya difiere de su captura de Wayback del 23 de octubre). Las celdas sin marca contienen afirmaciones de los proveedores. C significa que lo leímos en el código del commit 6337fc8; el README lo llama «no es un producto oficial de DeepSeek» y lo leímos sin ejecutarlo. N significa que no se indica en las páginas que leímos: una ausencia, no una prueba.

Agentes personales:

ControlGrok BotdotsDuet
Quién puede iniciar trabajoEl propietario y, en un Bot compartido, los compañeros de equipo en sus propios chats; rutinas ante eventos que otros pueden provocar; @bot en X, solo para tu propio BotEl propietario en ChatGPT, Slack o Teams; terceros: NEl propietario mediante un mensaje; sus propias verificaciones; terceros: N
Ejecución de prueba para trabajo proactivoN; una ejecución de prueba realiza trabajo realN; la investigación proactiva es de solo lecturaN
CredencialesLos tokens de conectores permanecen en el backend de CursorEl inicio de sesión seguro los mantiene fuera del contexto del modeloUn marcador en la máquina del agente; un proxy externo proporciona el valor
Revisión de accionesAuto Review basado en un modelo, activado por defectoUn Auto-review independiente; los pasos normales de solo lectura lo omitenUna verificación basada en el clasificador de modo automático
Dónde se ejecuta la revisiónNFuera de lo que dots puede modificarFuera del modelo; también «en tu propia computadora en la nube» (dos páginas)
Acciones irreversiblesTarjetas de aprobación; el correo de rutina espera como borrador. Merge: NSe confirman las compras y eliminaciones permanentes. Merge: NAprobación antes de enviar, pagar, firmar o eliminar. Merge: N
CI sobre el código del botNNN
Salida de redLos equipos sin una política permiten todo por defecto; la lista de permitidos es exclusiva de EnterpriseNN

Agentes para repos:

Controldsh-superagentCopilot cloud agent
Quién puede iniciar trabajoUna lista de permitidos por fuente, en el código (C)Usuarios con acceso de escritura; los demás nunca llegan al agente
Ejecución de prueba para trabajo proactivoModo observe, el predeterminado (C)N; se está desplegando un nivel de automatización que retiene los cambios en issues para revisión
CredencialesLa sesión del agente está en el directorio home del operador (C)El agente no puede ejecutar git push; los secretos de Agents le llegan como variables de entorno
Revisión de accionesNo se encontró un modelo revisor (C)PR en borrador; una persona revisa y hace merge
Dónde se ejecuta la revisiónNinguna; la protección de ramas de GitHub está fuera de la shell (C)En el PR; su firewall está dentro del entorno
Acciones irreversiblesUn prompt rige el merge automático; las aprobaciones quedan a cargo de la protección de ramas y no encontramos una verificación de que esté configurada (C)No puede aprobar ni hacer merge; una rama
CI sobre el código del botNo hay configuración de CI en la versión examinada (C)Retenido hasta que una persona lo apruebe; los administradores pueden desactivarlo. Sesiones en sandbox en la nube: no se indica
Salida de redNo se encontró ninguna restricción (C)Firewall activado por defecto, dentro del entorno

Lo que dicen las tablas, según nuestra lectura [INFERENCIA]:

  • La ubicación varía. dots dice que mantiene los controles que aplican Auto-review «fuera de los entornos que dots puede modificar». La página de seguridad de Duet dice que su verificación de aprobación se ejecuta «fuera del modelo que realiza el trabajo» y que las aprobaciones y verificaciones se ejecutan en «máquinas separadas a las que el modelo no puede acceder». Su página de producto dice que se «ejecutan en tu propia computadora en la nube», y no pudimos determinar si se trata del mismo lugar. Las páginas de Grok Bot describen un modelo de revisión independiente que «debería complementar, no reemplazar, el mínimo privilegio», pero no dicen dónde se ejecuta, y exigir su uso es exclusivo de Enterprise.
  • Copilot coloca sus límites en la plataforma de GitHub: una rama, sin merge y workflows retenidos. Su firewall es la excepción: la página de GitHub dice que solo cubre procesos iniciados mediante la herramienta Bash del agente y opera dentro del entorno que protege. GitHub también advierte que desactivar la retención de workflows puede exponer secretos a código no revisado.
  • dsh-superagent es un gate, no una barrera de aislamiento. Su documentación describe dos rutas alrededor del gate. Los mensajes de un Thread suscrito llegan a sus Tickets como contexto, lo que «no otorga autoridad adicional». El mensaje de una persona puede reanudar un Ticket finalizado «sin importar quién lo haya enviado», bajo el Mandate existente del Ticket (docs/002-authorization.md:20,127; código en src/core/intake.ts:38-51). Cada sesión se ejecuta como el usuario del sistema operativo del operador (docs/006-deployment.md:19).
  • Los niveles de los planes cambian los controles disponibles. La documentación de Grok Bot dice que sus computadoras se ejecutan en infraestructura alojada por Cursor y que los Bots separados no constituyen un límite de seguridad; además, las notas de disponibilidad anteriores cambiarán. Vuelve a verificar antes de depender de cualquier celda.

La estructura central: qué está fuera del alcance del agente

Si quitas la UI de los cinco, queda una estructura común, aunque los productos se ajusten a ella de manera desigual. Entran eventos, un gate decide quién puede iniciar trabajo, un worker realiza la tarea de forma aislada y las acciones solo salen después de una verificación. La implementación de abajo la representa como un plano de control que posee todas las credenciales y un sandbox que no posee ninguna. El plano de control es la raíz de confianza: si se compromete el loop que lee la salida del sandbox, también se comprometen todas las credenciales que posee. Por eso, la lista del final coloca al publicador en su propio proceso.

flowchart LR
  GH["Eventos de<br/>GitHub"] --> CP
  subgraph CP["Plano de control: posee todas las credenciales"]
    direction TB
    GATE["Gate y evaluación:<br/>lista de permitidos, modo,<br/>veredicto si es proactivo"] --> LP["Loop del agente:<br/>tarea + autorización, llamadas al modelo"]
    LP --> AR["Verificaciones:<br/>reglas de rama, política de diff"] --> PU["Publicador:<br/>token, push, PR"]
  end
  CP <-->|"llamadas a herramientas,<br/>git bundle"| SB
  subgraph SB["Sandbox: sin credenciales"]
    W["Worker: shell, archivos,<br/>sin red"]
  end
  CP --> OUT["GitHub:<br/>rama del agente, PR"]
  OUT -.-> CI["CI y previews:<br/>sin credenciales a menos<br/>que agregues un gate"]

Términos usados a continuación:

  • evento: una entrega de webhook, almacenada una sola vez por su id de entrega.
  • gate: una función pura que decide quién puede iniciar trabajo.
  • evaluación: un juicio del modelo, usado solo para candidatos proactivos.
  • tarea, autorización: una unidad de trabajo (la primitiva de tarea persistente de Agentes persistentes) y el registro de quién la pidió y bajo qué política.
  • sandbox: un entorno de ejecución por tarea, sin credenciales ni ruta de salida.
  • publicador: código del plano de control que realiza las escrituras revisadas.

El prototipo de DeepSeek llama Trigger al evento, Eval al gate y la evaluación juntos, Ticket a la tarea y Mandate a la autorización.

La Agents Rule of Two de Meta (31 de octubre de 2025), que reconoce la tríada letal de Simon Willison, dice que, hasta que la investigación pueda detectar y rechazar la inyección de prompts de forma confiable, un agente debería satisfacer no más de dos de estas tres propiedades en una sesión: [A] procesa entrada no confiable, [B] puede acceder a sistemas sensibles o datos privados, [C] puede cambiar el estado o comunicarse externamente. Necesitar las tres requiere supervisión, como la aprobación humana u otra verificación confiable. Aplicada a esta implementación, según nuestra lectura y no la de Meta: el sandbox y el loop cumplen A (y B si el repo es privado), y ninguno tiene una ruta propia para hacer push, aunque hasta que separes el publicador (punto 7 de la lista) el loop comparte un proceso con las credenciales. El publicador cumple B y C, y lo que escribe deriva de texto no confiable. Entre ambos hay una verificación determinista antes del push (Paso 4) y una revisión humana después. Ninguna persona aprueba el push en sí, y este puede iniciar CI. Por eso el Paso 5 tiene un segundo límite. La llamada al modelo también es un canal externo: el contenido del repo y el texto no confiable van a tu proveedor (fila de datos del Paso 1). Meta añade que cumplir la regla no es suficiente frente a otras amenazas, como spam, errores del agente, alucinaciones y privilegios excesivos, y que no es una meta final.

Paso 1: Define el presupuesto de atención

El primer artefacto es una política breve, no un prompt. La capacidad de revisión es donde se manifiesta el costo de un bot: Faros informa, para los 12 meses más recientes de telemetría de 22,000 desarrolladores en 4,000 equipos, que el tamaño promedio de los PR aumentó 71.8%, el tiempo en QA aumentó 300.6% y los PR fusionados sin revisión aumentaron 76.3% (después de un aumento de 31.3% seis meses antes). Las cifras provienen del proveedor, sus valores de referencia no están definidos y describen equipos con un uso intensivo de IA, no bots. Las interpretamos como una razón para presupuestar la atención [INFERENCIA].

DecisiónEmpieza conAplicado por
Quién puede iniciar trabajoUna lista de logins permitidos. Un desconocido no puede iniciar trabajo y la entrada de las tareas proviene solo de autores permitidosEl gate (paso 2) aplica la primera mitad. No construimos el filtro de entrada
Modoobserve: registrar lo que se aceptaría, sin aceptar nadaEl gate (paso 2)
Reglas proactivasDos, cada una con nombre y una instrucción de una líneaEl validador de la evaluación (paso 2)
NuncaHacer merge, editar .github/, escribir en cualquier ref que no sea agent/<task-id>Código del publicador (paso 3) para refs, la política de diff (paso 4) para .github/, revisiones obligatorias para merges [INFERENCIA]. Un conjunto de reglas que limite la App a agent/* es posible y no se probó aquí
VolumenUn límite diario de PR, un límite de palabras por comentario, un presupuesto de llamadas al modelo y un tiempo máximo por tareaTu outbox y tu lanzador, en código. No se construyeron aquí
DatosQué sale (el texto de las tareas y el código van al proveedor del modelo), retención y eliminación de datos sensibles antes de los logsTú, antes de la primera reproducción
ResponsableUna persona designada para la lista de permitidos, las reglas, la clave de la App y el mecanismo de paradaTú
Condiciones de paradaUn número mínimo de candidatos que se aceptarían por semana (paso 2); tu mediana de minutos desde la apertura del PR hasta la primera revisión antes del bot, que el bot no debe aumentar; y dos de los primeros diez PR cerrados sin merge (paso 6)Tú, antes de mirar la reproducción

dsh-superagent verifica su límite de 100 palabras en las respuestas de mensajería instantánea mediante un esquema y de nuevo al enviarlas (src/infra/text.ts:3-4, src/app/outbox.ts:29) y deja el volumen de PR a cargo del operador. Verifica los límites en código, no en un prompt. Si especificas solo lo que debe mantenerse, como plantea Después del desarrollo guiado por especificaciones, esta tabla es toda la especificación.

Paso 2: Ejecuta el gate en modo sombra

Primero, tres conceptos básicos de webhooks de la documentación de GitHub.

  • Verifica X-Hub-Signature-256, un HMAC-SHA256 del cuerpo sin procesar, con una comparación de tiempo constante, y rechaza el inicio si no hay un secreto de webhook (el encabezado está ausente cuando no se configura uno).
  • Responde con un 2xx en un plazo de 10 segundos. GitHub no vuelve a entregar automáticamente los fallos, pero puedes volver a entregarlos dentro de 3 días, y una nueva entrega conserva el mismo id X-GitHub-Delivery. Registra ese id en la misma transacción que encola el evento y luego responde.
  • En algunos eventos sender es el usuario ghost. En producción, usa una lista de permitidos basada en el sender.id numérico, porque los logins pueden cambiar de nombre y reutilizarse; el código de abajo usa logins para facilitar la lectura.

El propio agente de GitHub filtra caracteres ocultos, como comentarios HTML, de lo que pasa al modelo. Haz lo mismo antes de que el texto llegue al tuyo.

El gate es una función pura. Las solicitudes explícitas de remitentes permitidos funcionan en todos los modos, la misma regla que establece dsh-superagent (docs/002-authorization.md:101). El texto de un desconocido que no esté dirigido al bot se ignora en todos los modos, porque se convertiría en entrada de una tarea. Guarda los tres bloques de abajo como gate.ts, assessment.ts y replay.ts; se ejecutan en Node 22.19, que ejecuta archivos .ts directamente.

export type Mode = 'off' | 'observe' | 'auto';

export type Policy = {
  self: string; // the bot's own login
  allowed: string[]; // logins that may start work, by mention or by what they write unprompted
  mode: Mode;
};

export type BotEvent = {
  id: string; // webhook delivery id: the dedupe key
  sender: { login: string; type: 'User' | 'Bot' };
  body: string;
};

export type Gate =
  | { verdict: 'accept'; strength: 'explicit'; reason: string }
  | { verdict: 'assess'; reason: string }
  | { verdict: 'ignore'; reason: string };

// GitHub handles are letters, digits and hyphens, so they need no regex escaping.
// Matches "@bot" but not "@bot-2", "me@bot" or "@botanist", and skips fenced code, inline code and quoted lines,
// so quoting an old thread does not re-trigger the bot.
function mentions(body: string, login: string): boolean {
  const text = body.replace(/```[\s\S]*?```|`[^`\n]*`|^>.*$/gm, '');
  return new RegExp(`(^|[^\\w@])@${login}(?![\\w-])`, 'i').test(text);
}

/** Pure: no clock, no network, no model. The same event and policy always give the same verdict. */
export function decide(event: BotEvent, policy: Policy): Gate {
  const { sender, body } = event;
  const login = sender.login.toLowerCase();
  const allowed = policy.allowed.some((a) => a.toLowerCase() === login);

  // Never react to bots, including ourselves: this is what stops reply loops.
  if (sender.type === 'Bot' || login === policy.self.toLowerCase()) {
    return { verdict: 'ignore', reason: 'bot-sender' };
  }
  if (mentions(body, policy.self)) {
    return allowed
      ? { verdict: 'accept', strength: 'explicit', reason: 'allowed-sender-mention' }
      : { verdict: 'ignore', reason: 'mention-from-stranger' };
  }
  // Strangers' unaddressed text never becomes a candidate: it would become task input.
  if (!allowed) return { verdict: 'ignore', reason: 'stranger' };
  return policy.mode === 'off'
    ? { verdict: 'ignore', reason: 'mode-off' }
    : { verdict: 'assess', reason: 'unaddressed-allowed-sender' };
}

El gate verifica quién habló, no de quién es el texto que contendrá la tarea. Si un mantenedor permitido menciona al bot en el issue de un desconocido, la tarea lee el texto del desconocido. Construye la entrada de la tarea solo con autores permitidos y pasa cualquier otro contenido como datos citados. Eso limita la inyección de prompts, pero no la impide.

El modelo solo ve lo que el gate pasa a assess y se ejecuta sin herramientas ni credenciales: su única salida es un veredicto que el validador puede rechazar. Esa respuesta es JSON no confiable, así que el validador comprueba los tipos en tiempo de ejecución y el esquema exige considerar también los casos negativos: cada regla recibe un veredicto y una coincidencia debe citar el evento. dsh-superagent hace que su evaluación juzgue cada regla habilitada exactamente una vez y proporcione evidencia para aceptar (src/policy.ts:38-53), y su propio prompt admite que las declaraciones son «juicios semánticos, no pruebas proporcionadas por el esquema» (prompts/admission.md:39). Esta versión añade una verificación mecánica: la cita debe tener al menos ocho caracteres y aparecer en el evento, lo que detecta una cita inventada.

import type { Mode } from './gate.ts';

export type Rule = { id: string; instruction: string };
export type Verdict = { rule_id: string; matches: boolean; evidence?: string };
export type Assessment = { rule_assessments: Verdict[]; reason: string };

export type Outcome =
  | { verdict: 'accept'; strength: 'inferred'; rules: string[] }
  | { verdict: 'ignore'; reason: string; wouldAccept?: true; rules?: string[] };

const MIN_QUOTE = 8; // an arbitrary floor on a quote's length; tune it

/** The model's answer is untrusted JSON: check its types at runtime. Throws unless every rule is judged exactly once and every match quotes the event. */
export function validateAssessment(a: Assessment, rules: Rule[], eventBody: string): void {
  const known = new Set(rules.map((r) => r.id));
  const seen = new Set<string>();
  for (const v of a.rule_assessments) {
    if (typeof v.rule_id !== 'string' || typeof v.matches !== 'boolean') throw new Error('malformed verdict');
    if (!known.has(v.rule_id)) throw new Error(`unknown rule: ${v.rule_id}`);
    if (seen.has(v.rule_id)) throw new Error(`rule judged twice: ${v.rule_id}`);
    seen.add(v.rule_id);
    const quote = typeof v.evidence === 'string' ? v.evidence.trim() : '';
    if (v.matches && !(quote.length >= MIN_QUOTE && eventBody.includes(quote))) {
      throw new Error(`rule ${v.rule_id} matched without a quote from the event`);
    }
  }
  const missing = [...known].filter((id) => !seen.has(id));
  if (missing.length > 0) throw new Error(`rules not judged: ${missing.join(', ')}`);
}

/** observe records what would have been accepted; only auto accepts. */
export function outcome(a: Assessment, rules: Rule[], mode: Mode, eventBody: string): Outcome {
  validateAssessment(a, rules, eventBody);
  const matched = a.rule_assessments.filter((v) => v.matches).map((v) => v.rule_id);
  if (matched.length === 0) return { verdict: 'ignore', reason: 'no-rule-matched' };
  return mode === 'auto'
    ? { verdict: 'accept', strength: 'inferred', rules: matched }
    : { verdict: 'ignore', reason: 'observed', wouldAccept: true, rules: matched };
}

Ahora reproduce tu propio historial. Exporta los últimos cientos de eventos de comentarios en issues y PR (ten en cuenta la fila de datos del Paso 1: el texto de otras personas va a tu proveedor del modelo), pasa cada uno por decide, llama a tu modelo para los que devuelvan assess y cuenta los resultados. Este script lee dos archivos y usa respuestas registradas en lugar del modelo, así que puedes ejecutarlo tal cual y luego sustituirlas por tu propia llamada. Los comentarios al inicio muestran la estructura de cada archivo.

import { readFileSync } from 'node:fs';
import { decide, type BotEvent, type Policy } from './gate.ts';
import { outcome, type Assessment, type Rule } from './assessment.ts';

// events.json:      [{ "id": "e5", "sender": { "login": "alice", "type": "User" }, "body": "Typo in the README: 'recieve' should be 'receive'." }]
// assessments.json: { "e5": { "reason": "...", "rule_assessments": [{ "rule_id": "typo", "matches": true, "evidence": "'recieve' should be 'receive'" }, { "rule_id": "dead-link", "matches": false }] } }
// The assessments file stands in for the model call: one recorded answer per event id.
const [eventsFile, assessmentsFile, modeArg = 'observe'] = process.argv.slice(2);
const modes = ['off', 'observe', 'auto'] as const;
const mode = modes.find((m) => m === modeArg);
if (!mode) throw new Error(`mode must be one of: ${modes.join(', ')}`);
const events: BotEvent[] = JSON.parse(readFileSync(eventsFile, 'utf8'));
const recorded: Record<string, Assessment> = JSON.parse(readFileSync(assessmentsFile, 'utf8'));

const policy: Policy = { self: 'repo-bot', allowed: ['alice', 'bob'], mode };
const rules: Rule[] = [
  { id: 'typo', instruction: 'A report of a typo that states the corrected text.' },
  { id: 'dead-link', instruction: 'A report of a broken link that states the URL.' },
];

const funnel = new Map<string, number>();
const bump = (key: string) => funnel.set(key, (funnel.get(key) ?? 0) + 1);
const seen = new Set<string>();

for (const event of events) {
  bump('received');
  if (seen.has(event.id)) {
    bump('duplicate delivery'); // a redelivery: same id, no second task
    continue;
  }
  seen.add(event.id);

  const gate = decide(event, policy);
  if (gate.verdict === 'accept') {
    bump('accepted (explicit)');
    continue;
  }
  if (gate.verdict === 'ignore') {
    bump(`ignored: ${gate.reason}`);
    continue;
  }

  bump('assessed');
  try {
    const assessment = recorded[event.id];
    if (!assessment) throw new Error('no assessment recorded');
    const result = outcome(assessment, rules, mode, event.body);
    if (result.verdict === 'accept') bump('accepted (inferred)');
    else if (result.wouldAccept) bump('would accept');
    else bump(`ignored: ${result.reason}`);
  } catch (err) {
    bump(`assessment rejected: ${err instanceof Error ? err.message : String(err)}`);
  }
}

console.log(`mode=${mode}`);
for (const [key, n] of funnel) console.log(`${String(n).padStart(3)}  ${key}`);

Ejecutamos node replay.ts events.json assessments.json observe con once eventos sintéticos: una solicitud explícita, la mención de un desconocido, dos remitentes que son bots, el reporte espontáneo de un error tipográfico por parte de un desconocido, una nueva entrega y cinco comentarios espontáneos de miembros del equipo, dos de ellos con evaluaciones mal formadas a propósito.

mode=observe
 11  received
  1  accepted (explicit)
  1  ignored: mention-from-stranger
  2  ignored: bot-sender
  5  assessed
  2  would accept
  1  ignored: no-rule-matched
  1  assessment rejected: rules not judged: dead-link
  1  assessment rejected: rule typo matched without a quote from the event
  1  ignored: stranger
  1  duplicate delivery

En auto los dos casos que se aceptarían pasan a ser accepted (inferred), y el reporte de error tipográfico del desconocido sigue ignorándose. En off los cinco eventos evaluados pasan a ser ignored: mode-off. El embudo es lo importante. Anota tu umbral de parada del Paso 1 antes de leer tus resultados: si por semana se aceptarían menos candidatos de los que revisarías, detente aquí y conserva el gate como registro de clasificación. El modo sombra no mide la precisión. Registra lo que el bot habría hecho para que una persona lo juzgue y, hasta donde leímos, dsh-superagent tampoco tiene una evaluación offline.

Paso 3: Un sandbox por tarea, sin credenciales dentro

Ejecuta el loop del agente en el plano de control y trata el sandbox como un servidor de herramientas: ejecuta comandos de shell y edita archivos en una copia de trabajo por tarea, y devuelve salidas y un git bundle. No contiene ningún token de GitHub, ninguna clave del modelo ni acceso a la red. El plano de control obtiene el bundle, aplica las verificaciones del Paso 4 y solo entonces hace push.

Aquí está el punto de integración, ejecutado localmente. Construye la imagen una vez, con red, usando docker build -t bot-sandbox:local . junto a este Dockerfile, para que una tarea nunca instale nada. Cada tarea recibe un clon, nunca la propia copia de trabajo del plano de control, y un contenedor sin red. Guarda el script que aparece después del Dockerfile como seam.sh.

FROM alpine:3
RUN apk add --no-cache git
#!/bin/sh
# Control-plane side of the seam: hand a clone in, take a git bundle out.
# The container has no network and no credentials; the control plane inspects the bundle before anything leaves.
set -eu
work=$(mktemp -d)
git init -q "$work/control"
cd "$work/control"
printf 'You will recieve a reply.\n' > README.md
git add . && git -c user.name=cp -c user.email=cp@example.com commit -q --signoff -m 'add README'

# The sandbox gets a clone, never the control plane's own checkout.
mkdir "$work/handoff" && git clone -q . "$work/handoff/repo"

docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
  --user "$(id -u):$(id -g)" --pids-limit 64 --memory 256m -v "$work/handoff:/work" -w /work/repo bot-sandbox:local \
  sh -c 'sed -i "s/recieve/receive/" README.md &&
         git -c safe.directory=/work/repo -c user.name=bot -c user.email=bot@example.com commit -qam "Fix typo" --signoff &&
         git -c safe.directory=/work/repo bundle create /work/out.bundle HEAD~1..HEAD'

git fetch -q "$work/handoff/out.bundle" HEAD:refs/heads/candidate
git diff --numstat HEAD candidate

Imprime una línea separada por tabulaciones, 1, 1, README.md: una línea agregada, una eliminada y el error tipográfico corregido en la rama candidata. git fetch desde un bundle añade objetos y una ref. Trata el bundle como entrada hostil y no ejecutes git dentro de un directorio escrito por el sandbox.

¿Quién hace la llamada con privilegios? Los productos disponibles usan dos modelos.

PreguntaProxy que inyecta credencialesPublicador
Dónde reside la credencialFuera de la máquina del agente; el agente tiene un marcadorEn el plano de control; el sandbox no tiene nada
Qué ve el punto de controlUn flujo de solicitudes; la revisión solo encaja si el proxy aplica una política por solicitudUn artefacto, un diff y una descripción del PR; no se envía nada hasta que pasen las verificaciones
Git pushUna lista de permitidos de método y ruta no puede ver qué ref actualiza un push, porque los nombres de las refs viajan en el cuerpo del POST a /git-receive-pack; un handler tendría que analizar el protocolo de paquetesEl código solo escribe en agent/<task-id>
EjemplosDuet (marcador, proxy fuera de la máquina); handlers de salida de Cloudflare Sandbox; proxies de InfisicalCopilot cloud agent: el agente «no puede ejecutar git push directamente»
CostoMenos código; tú debes escribir la política por solicitudUn servicio que debes escribir y ejecutar

Elige el proxy cuando el agente deba llamar a una API por sí mismo y puedas aplicar una política por solicitud. Elige un publicador cuando la acción sea un artefacto revisable, como un diff [INFERENCIA]. Para las opciones de proxy, consulta Mantén los secretos fuera del agente.

La documentación de GitHub te da las piezas. Una App firma un JWT con RS256 que vence en un máximo de 10 minutos y lo intercambia en POST /app/installations/{id}/access_tokens por un token de instalación que vence después de una hora. Puedes limitar ese token a repositorios específicos (hasta 500) y a un subconjunto de los permisos de la App; no encontramos una opción para ramas. Hacer push de una rama y abrir un PR requiere permisos de escritura en Contents y Pull requests, y GitHub incluye el endpoint de merge, PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge, dentro del permiso de escritura en Contents. Así que «sin acción de merge» es una propiedad del código de tu publicador, no del token. En el Paso 4 se hace cumplir.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

/** One token per task: one repository, two permissions, one hour at most. */
export async function installationToken(f: typeof fetch, jwt: string, installationId: number, repo: string): Promise<string> {
  const res = await f(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
    method: 'POST',
    headers: { authorization: `Bearer ${jwt}`, accept: 'application/vnd.github+json' },
    body: JSON.stringify({
      repositories: [repo.split('/')[1]],
      permissions: { contents: 'write', pull_requests: 'write' },
    }),
  });
  if (res.status !== 201) throw new Error(`token request failed: ${res.status}`);
  const payload: unknown = await res.json();
  if (payload && typeof payload === 'object' && 'token' in payload && typeof payload.token === 'string') return payload.token;
  throw new Error('token response had no token');
}

/**
 * The only ref this code ever writes is agent/<task-id>. The token travels in an HTTP header that git reads from its
 * environment, not in argv and not in .git/config, and git gets a minimal environment of its own.
 */
export async function pushBranch(workdir: string, remote: string, token: string | null, taskId: string, sha = 'HEAD'): Promise<string> {
  if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(taskId)) throw new Error(`bad task id: ${taskId}`);
  if (!/^(HEAD|[0-9a-f]{7,40})$/.test(sha)) throw new Error(`bad sha: ${sha}`);
  const ref = `refs/heads/agent/${taskId}`;
  const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, GIT_TERMINAL_PROMPT: '0' };
  if (token) {
    const basic = Buffer.from(`x-access-token:${token}`).toString('base64');
    Object.assign(env, {
      GIT_CONFIG_COUNT: '1',
      GIT_CONFIG_KEY_0: `http.${new URL(remote).origin}/.extraheader`,
      GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`,
    });
  }
  await run('git', ['push', '--porcelain', remote, `${sha}:${ref}`], { cwd: workdir, env });
  return ref;
}

Hay dos piezas más en el mismo archivo que no se muestran: appJwt, ocho líneas de node:crypto, y openPullRequest, una llamada POST /pulls que no incluye merge. Las cubrimos con 26 pruebas pequeñas de node --test repartidas entre estos archivos. Las pruebas verifican el JWT contra una clave pública generada y el cuerpo de la solicitud del token contra una respuesta predefinida. Hacen push a un repo bare local para confirmar que agent/t-1 es la única rama creada y que se rechazan ids de tarea como ../main, a/b y Main. Un push a un servidor HTTP local que registra encabezados confirma que el token llega en el encabezado Authorization y nunca termina en .git/config (git 2.39.5). Nada de esto se ejecutó contra GitHub. El token sigue estando en el entorno del proceso hijo de git y puede ser leído por procesos del mismo usuario, por lo que git recibe un entorno mínimo.

Detente si la prueba de push alguna vez crea una ref distinta de agent/<task-id>. Una dificultad que encontramos al principio: un hook global de git rechazó nuestro commit de prueba por no tener una línea Signed-off-by, porque este sitio exige signoff. Un sign-off humano certifica que la persona revisó el cambio, así que decide de quién será el sign-off de un commit del agente; una identidad de bot con un trailer de atribución puede ser la opción más honesta [INFERENCIA].

Paso 4: Revisa la acción

Revisa el artefacto, no la descripción. Primero van las verificaciones deterministas y, si añades uno, después un modelo revisor. Esa es la regla de ubicación de Las señales de riesgo de los agentes son una configuración: coloca los controles estrictos donde las puntuaciones no puedan alcanzarlos.

Una verificación de protección de merge, antes de cada publicación. Como el token puede hacer merge, el plano de control se niega a publicar a menos que GitHub vaya a impedir un merge. GET /repos/{owner}/{repo}/rules/branches/{branch} devuelve las reglas activas que se aplican a una rama y solo necesita permiso de lectura de Metadata; los conjuntos de reglas en modo evaluate no se devuelven, así que no cuentan. La verificación solo pasa cuando una regla pull_request exige al menos una aprobación, y falla de forma cerrada ante cualquier otra respuesta, incluidos un 403 o un 404. Las páginas que leímos no dicen si los conjuntos de reglas funcionan en un repo privado del plan Free, así que verifica el tuyo. Ejecútala antes de cada publicación, no una sola vez al iniciar: las reglas pueden cambiar entre una y otra. Dos límites. El endpoint de un conjunto de reglas individual devuelve bypass_actors solo a quienes tienen acceso de escritura sobre ese conjunto, por lo que una verificación de solo lectura no puede demostrar que la App no está en la lista; confírmalo una vez manualmente. Y el número no dice nada sobre quién puede aprobar. El token del publicador tiene permiso de escritura en Pull requests, que incluye enviar revisiones, así que exige aprobaciones de personas y considera dismiss_stale_reviews_on_push y require_last_push_approval en la misma regla para que una aprobación no sobreviva a un push posterior del bot. No conectamos esas dos opciones. Según nuestra lectura, la regla del último push excluye a quien hizo push, que es la App, no la persona que hizo la solicitud. GitHub incluye «impide que el usuario que pidió a Copilot cloud agent crear un pull request lo apruebe» como control de Copilot; no encontramos un equivalente para una App personalizada, así que impedir que quien hizo la solicitud apruebe es aquí una regla del equipo.

/** Fail closed: refuse to publish unless the default branch requires a PR with at least one approval. */
export async function assertMergeProtection(f: typeof fetch, token: string, repo: string, branch: string): Promise<void> {
  const res = await f(`https://api.github.com/repos/${repo}/rules/branches/${branch}`, {
    headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' },
  });
  if (res.status !== 200) throw new Error(`cannot read the rules for ${branch} (${res.status}): refusing to publish`);
  const rules: unknown = await res.json();
  const needsApproval =
    Array.isArray(rules) && rules.some((r) => r?.type === 'pull_request' && (r.parameters?.required_approving_review_count ?? 0) >= 1);
  if (!needsApproval) throw new Error(`${branch} does not require an approving review: refusing to publish`);
}

La documentación muestra cada regla de la respuesta solo como un objeto, así que los nombres de los campos provienen de la referencia de reglas. Verifícalos contra la respuesta real de tu repo; nosotros ejecutamos la verificación contra respuestas predefinidas.

Una política de diff. El bundle es una salida no confiable. Calcula su diff con git diff --numstat -z --no-renames <base> candidate: sin las dos flags, git imprime un cambio de nombre como old => new y pone entre comillas las rutas inusuales, por lo que una lista de rutas denegadas no detecta ninguno de los dos casos. Rechaza el bundle cuando toque archivos que ejecutan código donde hay secretos, contenga un archivo binario o sea demasiado grande para que una persona lo lea.

export type Change = { path: string; added: number; deleted: number; binary?: true };

// An example tuned to one site: files that run code where secrets live (CI, builds, package scripts) or change what runs.
const PROTECTED = [/^\.github\//, /(^|\/)package(-lock)?\.json$/, /^pnpm-lock\.yaml$/, /^\.npmrc$/, /^wrangler\.jsonc?$/, /^astro\.config\./];

/** Deterministic checks first; a model reviewer, if you add one, comes after. */
export function diffViolations(changes: Change[], maxLines = 300): string[] {
  const out = changes.filter((c) => PROTECTED.some((p) => p.test(c.path))).map((c) => `protected path: ${c.path}`);
  out.push(...changes.filter((c) => c.binary).map((c) => `binary file: ${c.path}`));
  const lines = changes.reduce((n, c) => n + c.added + c.deleted, 0);
  if (lines > maxLines) out.push(`diff too large: ${lines} lines (limit ${maxLines})`);
  return out;
}

/** Parses `git diff --numstat -z --no-renames`: NUL-separated "<added>\t<deleted>\t<path>" records. Binary files show "-". */
export function parseNumstat(output: string): Change[] {
  return output.split('\0').filter(Boolean).map((record) => {
    const [added, deleted, ...path] = record.split('\t');
    const file = path.join('\t');
    return added === '-' || deleted === '-' ? { path: file, added: 0, deleted: 0, binary: true } : { path: file, added: Number(added), deleted: Number(deleted) };
  });
}

Lo probamos con salida real de git: se detectan un cambio de nombre hacia .github/workflows, una ruta con caracteres no ASCII y un archivo binario; sin las dos flags, el cambio de nombre pasa inadvertido. La lista es un ejemplo. En un repo donde cada archivo fuente y prueba se ejecuta durante el build, una lista de denegados es una defensa en profundidad, y el control real es que los jobs de las ramas no tengan credenciales (Paso 5). Para una primera regla, prefiere una lista de permitidos como docs/**/*.md.

Trata lo que escribe el publicador como salida dirigida a lectores no confiables: limita su longitud, escapa las menciones con @ y elimina imágenes y enlaces a hosts desconocidos. La URL de una imagen en Markdown puede enviar contenido del repo a un servidor cuando alguien abre el PR [INFERENCIA: podrían aplicarse mitigaciones del lado de GitHub]. No construimos este filtro. Detente si la verificación pasa en un repo sin revisión obligatoria.

Paso 5: Prueba dos límites

Una verificación que no puede fallar no demuestra nada, así que cada verificación de abajo incluye una ejecución que debe fallar.

Límite 1: el sandbox. El script se ejecuta dentro del contenedor e imprime una línea por verificación:

  • el entorno contiene solo los nombres que eligió el lanzador (solo nombres, nunca valores)
  • los archivos habituales de credenciales y el socket de Docker están ausentes
  • el conjunto límite de capacidades está vacío
  • no-new-privileges está activado y el sistema de archivos raíz es de solo lectura
  • fallan dos solicitudes de salida

Supón que el worker obedece una instrucción inyectada: el script intenta lo que intentaría un worker en esa situación. Guárdalo como contain.sh.

#!/bin/sh
# Runs INSIDE the sandbox. One line per check; exit 1 if anything leaked.
leaks=0
total=0
leak() { echo "LEAK  $1"; leaks=$((leaks + 1)); total=$((total + 1)); }
ok()   { echo "ok    $1"; total=$((total + 1)); }

# 1. The environment holds only names the launcher chose (an allowlist; names only, values are never printed).
extra=$(env | cut -d= -f1 | grep -vxE 'PATH|HOME|HOSTNAME|PWD|SHLVL|TERM|_' || true)
if [ -n "$extra" ]; then leak "unexpected environment variables: $(echo $extra | tr '\n' ' ')"; else ok "environment holds only the expected names"; fi

# 2. No credential files in the usual places (relative to HOME) and no Docker socket.
found=0
for f in "$HOME/.netrc" "$HOME/.git-credentials" "$HOME/.config/gh/hosts.yml" "$HOME/.ssh/id_ed25519" /var/run/docker.sock; do
  if [ -e "$f" ]; then leak "found $f"; found=1; fi
done
[ "$found" -eq 0 ] && ok "no credential files, no Docker socket"

# 3. No capability can be gained, no privilege escalation, no writable root filesystem.
if grep -q '^CapBnd:[[:space:]]*0000000000000000$' /proc/self/status; then ok "capability bounding set is empty"; else leak "capabilities available"; fi
if grep -q '^NoNewPrivs:[[:space:]]*1$' /proc/self/status; then ok "no-new-privileges is set"; else leak "process can gain privileges"; fi
if touch /.probe 2>/dev/null; then rm -f /.probe; leak "root filesystem is writable"; else ok "root filesystem is read-only"; fi

# 4. No route out: neither to GitHub nor to anything else.
if wget -q -T 3 -O /dev/null http://example.com 2>/dev/null; then leak "reached example.com"; else ok "cannot reach example.com"; fi
if wget -q -T 3 -O /dev/null https://api.github.com 2>/dev/null; then leak "reached api.github.com"; else ok "cannot reach api.github.com"; fi

if [ "$leaks" -eq 0 ]; then echo "NO LEAKS FOUND ($total checks)"; else echo "LEAKS FOUND: $leaks of $total checks"; exit 1; fi

Ejecútalo dos veces. La primera ejecución no tiene red ni capacidades, usa una raíz de solo lectura y un usuario que no es root. La segunda es el control: red y capacidades predeterminadas, root, un token falso en el entorno y un .netrc señuelo donde el script busca uno. Debe reportar filtraciones.

# A fake credential file for the control run to mount where the script looks for one.
printf 'machine github.com login decoy password not-a-real-credential\n' > decoy-netrc

docker run --rm --network none --read-only --cap-drop ALL \
  --security-opt no-new-privileges --user 10001:10001 \
  -v "$PWD/contain.sh:/contain.sh:ro" alpine:3 sh /contain.sh

docker run --rm -e GITHUB_TOKEN=ghp_fake_for_control_run \
  -v "$PWD/contain.sh:/contain.sh:ro" -v "$PWD/decoy-netrc:/root/.netrc:ro" \
  alpine:3 sh /contain.sh
ok    environment holds only the expected names
ok    no credential files, no Docker socket
ok    capability bounding set is empty
ok    no-new-privileges is set
ok    root filesystem is read-only
ok    cannot reach example.com
ok    cannot reach api.github.com
NO LEAKS FOUND (7 checks)

LEAK  unexpected environment variables: GITHUB_TOKEN 
LEAK  found /root/.netrc
LEAK  capabilities available
LEAK  process can gain privileges
LEAK  root filesystem is writable
LEAK  reached example.com
LEAK  reached api.github.com
LEAKS FOUND: 7 of 7 checks

La primera ejecución termina con código 0 y la ejecución de control con código 1. Esto muestra que estas flags producen estos valores en nuestro host Docker (servidor 28.3.3, arm64). La ejecución de control cambia varias cosas a la vez, así que muestra que el script puede fallar, no que cada flag importe por sí sola. No prueba --pids-limit, --memory, seccomp ni escapes. La documentación de Docker dice que --network none crea solo un dispositivo de loopback y que los contenedores de un host comparten su kernel, así que considera una microVM para código hostil [INFERENCIA]. Cloudflare describe su sandbox de contenedores como una microVM, y las preguntas frecuentes de seguridad de Grok Bot dicen que cada usuario recibe una microVM Firecracker. Sin red, apk add git falla aquí con un error de DNS y código de salida 1, así que una tarea que instala paquetes necesita una imagen preconstruida, fijada por digest y construida en CI con los scripts de instalación desactivados [INFERENCIA]. Un proxy de registro sería una ruta de salida: vuelve a ejecutar esta prueba con el proxy como única ruta.

Límite 2: el push. La prueba del sandbox no puede ver este límite: el sandbox no puede llegar a GitHub, pero la rama que produjo sí. GitHub dice que los eventos activados por el GITHUB_TOKEN del repositorio no crean nuevas ejecuciones de workflows, salvo los eventos workflow_dispatch y repository_dispatch, y con la salvedad de que un pull request que abra o actualice inicia ejecuciones en un estado que requiere aprobación. Para que esas ejecuciones se realicen «sin requerir aprobación», la página dice que se use un token de instalación de App o un token de acceso personal. Un publicador usa un token de App, así que interpretamos esto así: sus pushes y PR pueden iniciar CI sin esperar aprobación [INFERENCIA: la documentación describe tokens usados dentro de workflows]. push establece GITHUB_SHA al commit enviado y pull_request lo establece al commit de merge del PR, así que, según nuestra lectura, la rama controla cada script, configuración y prueba que llama el workflow. Los PR de forks no reciben secretos y obtienen un token de solo lectura. GitHub dice que cualquiera con acceso de escritura puede leer todos los secretos del repositorio y que un workflow solo lee un secreto si lo referencia. En conjunto, inferimos que los workflows del mismo repo pueden leer los secretos que referencian [INFERENCIA: ninguna página lo dice en una sola frase]. Un build de preview en una rama a la que se hizo push sigue la misma ruta: el comando de build ejecuta los scripts de la rama con las variables de build que tenga el entorno de preview.

Los controles, ordenados según si la rama puede editarlos:

ControlQué impide¿La rama puede editarlo?Estado
Los jobs activados por ramas no tienen credencialesQue scripts escritos por el bot lean secretosNo sin el permiso workflowsUna convención; el lint de abajo la verifica
Entornos con revisores obligatoriosQue un job lea secretos del entorno antes de que una persona lo apruebeNoDocumentado; solo repos públicos en Free, Pro y Team (los repos privados probablemente necesiten Enterprise [INFERENCIA])
Sin permiso workflows para la AppEdiciones del YAML de workflowsNoPermiso documentado; la documentación no muestra el rechazo [INFERENCIA]
Política de diff (Paso 4)Bundles que tocan archivos de CI, build o paquetesNo: se ejecuta en el plano de controlNuestro
Builds de preview desactivados para agent/*Que los builds de preview ejecuten los scripts de la ramaNoDocumentado para el control de ramas de Cloudflare Pages (documentación actualizada 2026-04-21)
Condiciones if: y filtros de rama en el workflowPocoSí, si la App puede editar workflowsOrientativo

Copilot cloud agent de GitHub tiene la versión más sólida: por defecto, según la documentación de GitHub al 2 de noviembre de 2026, sus workflows no se ejecutan hasta que un usuario con acceso de escritura haga clic en Approve and run workflows. Es una configuración de Copilot, y las páginas que leímos no dicen si se aplica a sesiones en sandbox en la nube. Para los PR del mismo repo de una App personalizada no encontramos un gate de aprobación nativo en las páginas que leímos. Un diseño basado en forks, donde el bot hace push a un fork, elimina los secretos de los workflows del PR, pero la documentación que leímos no dice cómo una App es propietaria de un fork y hace push a él.

El lint lee .github/workflows/*.yml, enumera los workflows que un push o PR del bot puede alcanzar y señala los jobs que referencian secretos sin nombrar un entorno. Usa una dependencia, yaml. Guárdalo como workflow-exposure.ts y ejecuta node workflow-exposure.ts <dir>; sin argumento, lee .github/workflows.

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse } from 'yaml';

const dir = process.argv[2] ?? '.github/workflows';

// Triggers that a bot's push or PR can set off. Branch filters are not evaluated: a workflow limited to main is reported too.
const REACHABLE = ['push', 'pull_request', 'pull_request_target', 'pull_request_review', 'pull_request_review_comment', 'issue_comment', 'create', 'workflow_run', 'check_run', 'check_suite', 'status'];

// secrets.NAME, secrets['NAME'], and a bare `secrets` (toJSON(secrets), `secrets: inherit`). Over-reports on the word.
function secretRefs(value: unknown): string[] {
  const text = JSON.stringify(value);
  const names = [...text.matchAll(/\bsecrets\b(?:\.([A-Za-z_]\w*)|\[\\?['"]([^'"\\]+)\\?['"]\])?/g)].map((m) => m[1] ?? m[2] ?? '(all)');
  return [...new Set(names)].filter((n) => n !== 'GITHUB_TOKEN');
}

let exposed = 0;
for (const file of readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort()) {
  const { jobs = {}, ...top } = parse(readFileSync(join(dir, file), 'utf8'));
  const on = top.on;
  const names: string[] = typeof on === 'string' ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
  const reachable = names.filter((n) => REACHABLE.includes(n));
  console.log(`${file}: ${reachable.length ? `reachable from a bot branch via ${reachable.join(', ')}` : 'not reachable from a push or PR'}`);
  if (reachable.length === 0) continue;

  const parts: [string, unknown][] = [['(workflow-level)', top], ...Object.entries<unknown>(jobs)];
  for (const [name, part] of parts) {
    const secrets = secretRefs(part);
    const gated = typeof part === 'object' && part !== null && 'environment' in part;
    const verdict = secrets.length === 0 ? 'no secret reference found' : gated ? 'gated: check the environment has required reviewers' : 'EXPOSED';
    if (verdict === 'EXPOSED') exposed++;
    if (name !== '(workflow-level)' || secrets.length > 0) console.log(`  ${name}: ${secrets.join(', ') || 'no secrets'} -> ${verdict}`);
  }
}
process.exit(exposed > 0 ? 1 : 0);

Este sitio no tiene un directorio .github/, así que lo ejecutamos con seis workflows de prueba:

  • ci.yml: pruebas, más un deploy de preview que usa un token de Cloudflare en cada push
  • cron.yml: un job nocturno
  • deploy.yml: un deploy mediante un entorno
  • lint.yml: un job solo de lint
  • target.yml: un workflow pull_request_target que lee toJSON(secrets)
  • top-env.yml: un env a nivel de workflow con un secreto en sintaxis de corchetes
ci.yml: reachable from a bot branch via push, pull_request
  test: no secrets -> no secret reference found
  preview: CLOUDFLARE_API_TOKEN -> EXPOSED
cron.yml: not reachable from a push or PR
deploy.yml: reachable from a bot branch via push
  deploy: CLOUDFLARE_API_TOKEN -> gated: check the environment has required reviewers
lint.yml: reachable from a bot branch via pull_request
  lint: no secrets -> no secret reference found
target.yml: reachable from a bot branch via pull_request_target
  label: (all) -> EXPOSED
top-env.yml: reachable from a bot branch via push
  (workflow-level): PROD_TOKEN -> EXPOSED
  build: no secrets -> no secret reference found

Termina con código 1 por las tres líneas expuestas. Los filtros de rama no se evalúan, así que deploy.yml, que un filtro real podría limitar a main, también aparece en la lista; verifica los filtros manualmente. El lint reporta de más respecto a los filtros y a la palabra secrets, y reporta de menos en lo que no modela: OIDC (id-token: write), un GITHUB_TOKEN con capacidad de escritura, runners autoalojados, workflows reutilizables, secretos leídos por scripts que llama el workflow y workflows de la rama predeterminada que colocan texto escrito por el bot, como el título de un PR, en una línea run:. «No se encontró ninguna referencia a secretos» no significa «seguro».

La versión real: en una copia descartable de tu repo, solo con valores de secretos señuelo y con la App instalada únicamente allí, haz push de agent/probe con el token del publicador y abre la pestaña Actions. Cada job que se inicie es un job que el código del bot puede ejecutar. Un job que tiene un secreto y se inicia sin esperar aprobación es un fallo. No ejecutamos esta prueba; requiere un repo descartable y una App.

Ninguna de las dos verificaciones garantiza que un PR malicioso pero convincente sea seguro. Esa tarea le corresponde al revisor y a los límites del Paso 1.

Paso 6: Empieza con una regla sencilla

Activa auto para una regla, con el límite diario del Paso 1. Toma la regla de errores tipográficos del Paso 2: es acotada, su acción es un PR pequeño limitado a documentación y el embudo te dice cuántos candidatos esperar por semana. Observa el tiempo de revisión por PR durante unas semanas. Detente si la mediana de minutos desde la apertura del PR hasta la primera revisión supera la mediana anterior al bot del Paso 1, o si dos de los primeros diez PR se cierran sin merge.

Si una regla no requiere juicio, conviértela en una verificación de CI. El CLAUDE.md de este sitio dice que se actualice updated cuando se edita un artículo en inglés, para que su traducción al español aparezca como desactualizada. Comprobamos qué ocurre cuando se olvida esa actualización, en una copia temporal de un par de artículos: después de una edición solo de texto, npm run i18n:status todavía decía que estaba al día y check-translation pasaba; después de actualizar updated, decía que estaba desactualizada. Una breve verificación de CI detecta eso mejor que un bot.

Antes de activar auto

  1. El gate aplica la lista de permitidos a los eventos proactivos y una prueba demuestra que un comentario coincidente de un desconocido nunca es accepted (inferred) (Paso 2).
  2. El publicador solo escribe en agent/<task-id>. Decide si un conjunto de reglas también limita la App a ese espacio de nombres y pruébalo en un repo descartable; nosotros no lo hemos hecho.
  3. Una verificación previa a la publicación comprueba las aprobaciones obligatorias, la anulación de revisiones obsoletas y la aprobación del último push; solo construimos la primera, así que amplíala. Decide si la persona que hizo la solicitud puede aprobar el PR. «Allow GitHub Actions to create and approve pull requests» está desactivado [INFERENCIA: si una aprobación de ese tipo contaría no está documentado en lo que leímos]. La App no está en una lista de excepciones: verifícalo una vez en un repo descartable.
  4. La CI activada por ramas no tiene credenciales. El lint pasa y ejecutaste la prueba real una vez.
  5. Una flag halt que tanto el gate como el publicador verifican, y un runbook ensayado. Activa la flag. Suspende la instalación de la App o elimina su clave privada. Revoca el token activo (DELETE /installation/token revoca el token usado para llamarlo). Cierra los PR, elimina las ramas agent/* y purga la cola de eventos antes de reanudar.
  6. Los presupuestos de llamadas al modelo, tokens, tiempo máximo y PR se aplican en código.
  7. El publicador se ejecuta como un proceso o usuario separado y es el único que posee la clave privada de la App, guardada en un gestor de secretos.
  8. Decidiste qué sale hacia el proveedor del modelo, cuánto tiempo se conservan los logs y quién es responsable del riesgo residual de un PR malicioso pero convincente.
  9. El texto de entrada y salida se filtra: la entrada de las tareas proviene solo de autores permitidos, se eliminan sus caracteres ocultos y el publicador elimina las menciones con @, las imágenes y los enlaces a hosts desconocidos de lo que escribe. No construimos ninguno de estos filtros.

Construir o adoptar

Los runners alojados ya cubren parte de esto. Los datos provienen de la documentación de cada proveedor al 2 de noviembre de 2026, y todos pueden cambiar.

  • Copilot cloud agent: activación por usuarios con acceso de escritura, una rama, merge humano y workflows retenidos (consulta la tabla de agentes para repos de arriba). La página de GitHub dice que la nueva experiencia de Copilot en la nube está disponible para planes de pago desde el 28 de octubre de 2026, sujeta a las políticas de la organización y la empresa, mientras que los planes Free conservan la experiencia anterior de Copilot Chat. Llama a la anterior «Copilot cloud agent más Copilot Chat» y dice que Copilot solo funciona con repositorios alojados en GitHub. Las páginas de seguridad que citamos todavía dicen «cloud agent» y no dicen si la retención de workflows se aplica a sesiones en sandbox en la nube.
  • Claude Code Action: se ejecuta en tu job de Actions, con la clave del modelo y un token de GitHub en el mismo job (el de la App de GitHub de Claude, a menos que pases github_token). Según las preguntas frecuentes de la action, las reglas de hacer push solo a la rama en la que se invocó y nunca hacer force push ni realizar operaciones destructivas «se aplican en el system prompt de Claude». La action está en v1, pero una página de su repo (docs/usage.md, consultada el 2 de noviembre de 2026) todavía dice «actualmente en beta».
  • Codex cloud: una VM por tarea; los secretos de red llegan a los programas como marcadores que un proxy sustituye, para HTTPS en el puerto 443. OpenAI indica que GitLab y GitHub Enterprise Server autoalojado no están soportados actualmente y están en su hoja de ruta, y dice que planea retirar Codex Cloud (Legacy), sin fecha. Según su página de GitHub, una tarea solicitada mediante una mención a @codex en un PR inicia un chat en la nube de la versión anterior, así que no sabemos si los secretos con marcadores se aplican a ese caso. Esa página dice que Codex «puede hacer push de una corrección a la rama cuando tiene permiso para hacerlo» y que las reglas de revisión no reemplazan las protecciones de rama. No encontramos nada sobre el alcance de ramas de Codex, quién puede usar @codex en un PR ni la aprobación de workflows.

Ninguno de los tres documenta una ejecución de prueba para trabajo proactivo. Adopta uno cuando el trabajo se limite a GitHub, las activaciones por usuarios con acceso de escritura sean suficientes y aceptes los controles del proveedor. Construye uno cuando necesites reglas de admisión propias, evaluación en modo sombra del trabajo proactivo, una política de credenciales que controles, fuentes más allá de GitHub o cuando el objetivo sea aprender. En ambos casos, verifica los mismos dos límites.

Si lo construyes, omite lo que ofrecen los agentes personales: tu propio panel (los issues y PR de GitHub son el panel), voz, chats grupales, memoria por usuario y una separación entre router y worker. La sesión principal de dsh-superagent enruta y un worker separado implementa (docs/004-prompts.md:77-78), y cada sesión se ejecuta con danger-full-access (docs/006-deployment.md:19), así que, según nuestra lectura, la separación no constituye un límite de privilegios. Informes de segunda mano dicen que sus cifras de velocidad provienen de una simulación sintética, y no pudimos abrir el artículo de diseño desde una página oficial, así que no las usamos.

Lo que no sabemos

  • Los productos. No probamos ninguno de los cinco. Las páginas que abrimos no indican dónde se ejecuta Auto Review de Grok Bot, y las dos páginas de Duet describen de forma distinta dónde se ejecutan sus verificaciones. La página Managed Agents de Anthropic, modificada por última vez el 2026-10-30, dice dos cosas: el token del repo está configurado en el remote de git del sandbox y los tokens nunca deberían ser accesibles desde el sandbox. No dice cómo pueden cumplirse ambas cosas, así que no lo contamos como ninguno de los dos diseños.
  • GitHub, para una App personalizada. No sabemos si las revisiones obligatorias bloquean la llamada de merge, si la propia revisión de una App cuenta para la regla de aprobación ni qué devuelve current_user_can_bypass para un token de instalación. Tampoco sabemos cómo una App es propietaria de un fork ni cómo se ve un push sin el permiso workflows. El publicador y la verificación se ejecutaron contra respuestas predefinidas y sustitutos locales, no contra GitHub.
  • La ruta de secretos del mismo repo. La inferimos a partir de la documentación. Pruébala en un repo descartable. Tampoco probamos si la restricción de ramas de despliegue de un entorno mantiene a agent/* alejado de los secretos de ese entorno, y el lint no modela workflows de la rama predeterminada que colocan texto escrito por el bot en una línea run:.
  • Un kernel hostil. Docker comparte el del host. Probamos la red, las capacidades, los archivos y el entorno, no escapes, seccomp ni límites de recursos.
  • El revisor. Un PR malicioso pero convincente supera ambas verificaciones. Según las cifras de Faros, la capacidad de revisión es el límite más probable, y aquí la inyección de prompts se limita, pero no se impide.
  • Lo que dejamos sin construir: el outbox y sus límites, la flag de parada, la custodia de claves, los filtros de entrada y salida, las verificaciones de revisiones obsoletas y del último push, y los términos de datos del proveedor del modelo. La lista menciona cada punto.

Fuentes

Productos, todos consultados el 2026-11-02 salvo que se indique otra fecha:

Funcionamiento de GitHub, documentación sin fecha, consultada el 2026-11-02:

Marco de seguridad, contenedores, alojamiento y datos: