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:
| Control | Grok Bot | dots | Duet |
|---|---|---|---|
| Quién puede iniciar trabajo | El 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 Bot | El propietario en ChatGPT, Slack o Teams; terceros: N | El propietario mediante un mensaje; sus propias verificaciones; terceros: N |
| Ejecución de prueba para trabajo proactivo | N; una ejecución de prueba realiza trabajo real | N; la investigación proactiva es de solo lectura | N |
| Credenciales | Los tokens de conectores permanecen en el backend de Cursor | El inicio de sesión seguro los mantiene fuera del contexto del modelo | Un marcador en la máquina del agente; un proxy externo proporciona el valor |
| Revisión de acciones | Auto Review basado en un modelo, activado por defecto | Un Auto-review independiente; los pasos normales de solo lectura lo omiten | Una verificación basada en el clasificador de modo automático |
| Dónde se ejecuta la revisión | N | Fuera de lo que dots puede modificar | Fuera del modelo; también «en tu propia computadora en la nube» (dos páginas) |
| Acciones irreversibles | Tarjetas de aprobación; el correo de rutina espera como borrador. Merge: N | Se confirman las compras y eliminaciones permanentes. Merge: N | Aprobación antes de enviar, pagar, firmar o eliminar. Merge: N |
| CI sobre el código del bot | N | N | N |
| Salida de red | Los equipos sin una política permiten todo por defecto; la lista de permitidos es exclusiva de Enterprise | N | N |
Agentes para repos:
| Control | dsh-superagent | Copilot cloud agent |
|---|---|---|
| Quién puede iniciar trabajo | Una 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 proactivo | Modo observe, el predeterminado (C) | N; se está desplegando un nivel de automatización que retiene los cambios en issues para revisión |
| Credenciales | La 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 acciones | No se encontró un modelo revisor (C) | PR en borrador; una persona revisa y hace merge |
| Dónde se ejecuta la revisión | Ninguna; la protección de ramas de GitHub está fuera de la shell (C) | En el PR; su firewall está dentro del entorno |
| Acciones irreversibles | Un 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 bot | No 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 red | No 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-superagentes 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 ensrc/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ón | Empieza con | Aplicado por |
|---|---|---|
| Quién puede iniciar trabajo | Una lista de logins permitidos. Un desconocido no puede iniciar trabajo y la entrada de las tareas proviene solo de autores permitidos | El gate (paso 2) aplica la primera mitad. No construimos el filtro de entrada |
| Modo | observe: registrar lo que se aceptaría, sin aceptar nada | El gate (paso 2) |
| Reglas proactivas | Dos, cada una con nombre y una instrucción de una línea | El validador de la evaluación (paso 2) |
| Nunca | Hacer 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í |
| Volumen | Un límite diario de PR, un límite de palabras por comentario, un presupuesto de llamadas al modelo y un tiempo máximo por tarea | Tu outbox y tu lanzador, en código. No se construyeron aquí |
| Datos | Qué 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 logs | Tú, antes de la primera reproducción |
| Responsable | Una persona designada para la lista de permitidos, las reglas, la clave de la App y el mecanismo de parada | Tú |
| Condiciones de parada | Un 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
senderes el usuarioghost. En producción, usa una lista de permitidos basada en elsender.idnumé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.
| Pregunta | Proxy que inyecta credenciales | Publicador |
|---|---|---|
| Dónde reside la credencial | Fuera de la máquina del agente; el agente tiene un marcador | En el plano de control; el sandbox no tiene nada |
| Qué ve el punto de control | Un flujo de solicitudes; la revisión solo encaja si el proxy aplica una política por solicitud | Un artefacto, un diff y una descripción del PR; no se envía nada hasta que pasen las verificaciones |
| Git push | Una 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 paquetes | El código solo escribe en agent/<task-id> |
| Ejemplos | Duet (marcador, proxy fuera de la máquina); handlers de salida de Cloudflare Sandbox; proxies de Infisical | Copilot cloud agent: el agente «no puede ejecutar git push directamente» |
| Costo | Menos código; tú debes escribir la política por solicitud | Un 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-privilegesestá 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:
| Control | Qué impide | ¿La rama puede editarlo? | Estado |
|---|---|---|---|
| Los jobs activados por ramas no tienen credenciales | Que scripts escritos por el bot lean secretos | No sin el permiso workflows | Una convención; el lint de abajo la verifica |
| Entornos con revisores obligatorios | Que un job lea secretos del entorno antes de que una persona lo apruebe | No | Documentado; solo repos públicos en Free, Pro y Team (los repos privados probablemente necesiten Enterprise [INFERENCIA]) |
Sin permiso workflows para la App | Ediciones del YAML de workflows | No | Permiso documentado; la documentación no muestra el rechazo [INFERENCIA] |
| Política de diff (Paso 4) | Bundles que tocan archivos de CI, build o paquetes | No: se ejecuta en el plano de control | Nuestro |
Builds de preview desactivados para agent/* | Que los builds de preview ejecuten los scripts de la rama | No | Documentado para el control de ramas de Cloudflare Pages (documentación actualizada 2026-04-21) |
Condiciones if: y filtros de rama en el workflow | Poco | Sí, si la App puede editar workflows | Orientativo |
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 pushcron.yml: un job nocturnodeploy.yml: un deploy mediante un entornolint.yml: un job solo de linttarget.yml: un workflowpull_request_targetque leetoJSON(secrets)top-env.yml: unenva 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
- 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). - 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. - 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.
- La CI activada por ramas no tiene credenciales. El lint pasa y ejecutaste la prueba real una vez.
- Una flag
haltque 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/tokenrevoca el token usado para llamarlo). Cierra los PR, elimina las ramasagent/*y purga la cola de eventos antes de reanudar. - Los presupuestos de llamadas al modelo, tokens, tiempo máximo y PR se aplican en código.
- 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.
- 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.
- 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
@codexen 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@codexen 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_bypasspara 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 permisoworkflows. 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ínearun:. - 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:
- xAI, Diseño de Grok Bot para un mundo de agentes persistentes, 2026-09-03.
- xAI, documentación de Grok Bot (sin fecha): seguridad, aprobaciones, seguridad y privacidad, skills, rutinas y automatizaciones, etiquetar a @bot en X, descripción general, preguntas frecuentes de seguridad.
- OpenAI, Presentación de dots y Cómo incorporamos seguridad y privacidad en dots, 2026-09-29.
- Anthropic, Presentación de Claude Duet, 2026-10-21; Seguridad y privacidad de Claude Duet, 2026-10-23; página de producto de Claude Duet, sin fecha.
- Anthropic, Escalar agentes administrados, publicado el 2026-04-08, modificado el 2026-10-30.
- Equipo DeepSeek Harness, dsh-superagent-prototype en el commit 6337fc8, 2026-10-31. Leído, no ejecutado.
- Fuentes de segunda mano, solo para la salvedad sobre la simulación: IT之家 vía ifeng, 2026-10-31, y APPSO vía 53ai, 2026-10-31.
- GitHub Docs, Riesgos y mitigaciones para GitHub Copilot cloud agent, sin fecha; y sus páginas sobre firewall, revisión de resultados, Copilot en GitHub, secretos y variables y justificación, confianza y aprobaciones.
- GitHub Docs, Configuración de GitHub Copilot en la nube, sin fecha.
- Anthropic, Claude Code GitHub Actions y la documentación del repositorio claude-code-action.
- OpenAI, documentación de Codex cloud, sin fecha: entornos en la nube y GitHub.
- Capturas de Wayback de cinco páginas fundamentales sin fecha o sin indexar, cada una verificada para la cita clave de esa página: artículo de seguridad de dots (2026-10-12), página de producto de Duet (2026-11-02), seguridad de Grok Bot (2026-10-17), riesgos y mitigaciones de Copilot (2026-10-17) y Copilot en GitHub (2026-11-02). Una captura puede diferir de la página activa.
Funcionamiento de GitHub, documentación sin fecha, consultada el 2026-11-02:
- GitHub Docs, generación de un JWT, autenticación como una instalación, REST: GitHub Apps, REST: instalaciones de Apps, permisos requeridos para GitHub Apps, elección de permisos.
- GitHub Docs, validación de entregas de webhooks, buenas prácticas para webhooks, nueva entrega de webhooks, eventos y payloads de webhooks.
- GitHub Docs, endpoints de la API REST para reglas, administración de la configuración de GitHub Actions para un repositorio.
- GitHub Docs, GITHUB_TOKEN, eventos que activan workflows, uso seguro, secretos, despliegues y entornos.
- Git, protocolo HTTP, protocolo de paquetes, variables de entorno de git-config, git-diff.
Marco de seguridad, contenedores, alojamiento y datos:
- Meta AI, Agents Rule of Two: un enfoque práctico para la seguridad de agentes de IA, 2025-10-31. Simon Willison, La tríada letal para agentes de IA, 2025-06-16.
- Docker Docs, driver de red none, docker container run, ¿Qué es un contenedor?, sin fecha.
- Cloudflare Docs, Sandboxes (actualizado el 2026-09-30), seguridad de Sandbox (actualizado el 2026-10-22) y controles de despliegue por rama de Pages (actualizado el 2026-04-21).
- Faros, La trampa de la velocidad: 8 conclusiones de la investigación más reciente de Faros sobre ingeniería con IA, 2026-09-18. Cifras proporcionadas por el proveedor.
- Artículos relacionados en este sitio: Mantén los secretos fuera del agente, Las señales de riesgo de los agentes son una configuración, Después del desarrollo guiado por especificaciones, Agentes persistentes.