nexakit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -1
- package/README.md +40 -30
- package/assets/brand/README.md +0 -2
- package/assets/runtime/package-lock.json +2 -2
- package/bin/agent-task.js +12 -10
- package/bin/agent-toolkit-gemini.js +11 -9
- package/bin/agent-toolkit-inbox.js +12 -10
- package/bin/agent-toolkit-mcp.js +14 -8
- package/bin/agent-toolkit.js +12 -9
- package/bin/nexakit.js +12 -9
- package/bin/project-memory-mcp.js +12 -9
- package/bin/project-memory.js +5 -3
- package/{skills/depurar → catalogs/skills/vercel-composition-patterns}/LICENSE.txt +6 -6
- package/catalogs/skills/vercel-react-best-practices/LICENSE.txt +27 -0
- package/catalogs/skills.json +70 -35
- package/docs/skills/evidencias/2026-10-28-recomendador-preparacion.md +1 -1
- package/package.json +5 -3
- package/plugins/nexakit-modelo/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-modelo/hooks/hooks.json +1 -0
- package/plugins/nexakit-modelo/hooks/register.js +48 -0
- package/plugins/nexakit-modelo/hooks/status.js +8 -0
- package/plugins/nexakit-modelo/tests/register.test.ts +21 -0
- package/plugins/nexakit-panel/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-panel/README.md +20 -0
- package/plugins/nexakit-panel/hooks/hooks.json +1 -0
- package/plugins/nexakit-panel/hooks/lienzo.js +382 -0
- package/plugins/nexakit-panel/hooks/panel.js +279 -0
- package/plugins/nexakit-panel/hooks/register.tsx +248 -0
- package/plugins/nexakit-panel/tests/lienzo.test.ts +142 -0
- package/plugins/nexakit-panel/tests/panel.test.ts +240 -0
- package/plugins/nexakit-panel/tests/register.test.ts +167 -0
- package/plugins/nexakit-panel/types/index.d.ts +41 -0
- package/plugins/nexakit-sesion/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-sesion/README.md +12 -0
- package/plugins/nexakit-sesion/hooks/aviso.js +38 -0
- package/plugins/nexakit-sesion/hooks/hooks.json +1 -0
- package/plugins/nexakit-sesion/hooks/register.js +28 -0
- package/plugins/nexakit-sesion/tests/register.test.ts +96 -0
- package/schemas/skill-catalog.schema.json +9 -2
- package/skills/cerrar-memoria/SKILL.md +6 -2
- package/skills/cerrar-memoria/references/cierre.md +5 -1
- package/skills/elegir-modelo/SKILL.md +68 -0
- package/skills/elegir-modelo/modelos.mjs +108 -0
- package/skills/gemini/SKILL.md +1 -1
- package/skills/project-handoff/SKILL.md +39 -12
- package/skills/project-handoff/references/handoff-template.md +24 -17
- package/skills/quality-gate/SKILL.md +6 -12
- package/skills/revisar/SKILL.md +6 -16
- package/skills/{desarrollo-con-pruebas/LICENSE.txt → revisar-skill/LICENSE-find-skills.txt} +5 -5
- package/skills/revisar-skill/SKILL.md +94 -0
- package/skills/revision-cruzada/SKILL.md +53 -475
- package/skills/web-design-guidelines/AGENTS.md +156 -0
- package/skills/{spec-driven-development → web-design-guidelines}/LICENSE.txt +1 -6
- package/skills/web-design-guidelines/SKILL.md +20 -30
- package/src/agent-models.js +53 -13
- package/src/brand.js +117 -0
- package/src/cli-executable.js +5 -3
- package/src/cli.js +16 -9
- package/src/connect.js +56 -4
- package/src/init.js +9 -1
- package/src/install-lock.js +127 -0
- package/src/install-registry.js +61 -0
- package/src/legacy-repair.js +71 -0
- package/src/link-states.js +172 -0
- package/src/memory-locator.js +118 -0
- package/src/missing-dependencies.js +21 -0
- package/src/model-command.js +147 -0
- package/src/panel-cli.js +182 -0
- package/src/project-root.js +41 -0
- package/src/project-signals.js +339 -0
- package/src/recommend-report.js +77 -0
- package/src/review-instructions.js +211 -0
- package/src/review-policy.js +35 -0
- package/src/revision-adapters.js +157 -0
- package/src/revision-context.js +86 -0
- package/src/revision-isolation.js +222 -0
- package/src/revision-process.js +103 -0
- package/src/revision-provenance.js +89 -0
- package/src/revision-snapshot.js +120 -0
- package/src/revision.js +242 -0
- package/src/setup-apply.js +231 -0
- package/src/setup-plan.js +231 -0
- package/src/setup-state.js +277 -0
- package/src/setup-tui.js +555 -0
- package/src/skill-catalog-add.js +91 -0
- package/src/skill-catalog-audit.js +26 -27
- package/src/skill-files.js +39 -0
- package/src/skill-recommendations.js +321 -208
- package/src/skill-scan-report.js +14 -0
- package/src/skill-scan.js +178 -0
- package/src/stable-command.js +53 -0
- package/src/stable-install.js +222 -0
- package/src/superpowers.js +133 -0
- package/src/sync-apply.js +8 -5
- package/src/sync-files.js +2 -1
- package/src/terminal-text.js +68 -0
- package/src/toolkit-cli.js +397 -70
- package/src/toolkit-commands-guide.js +53 -39
- package/src/toolkit-setup.js +507 -110
- package/assets/brand/agent-toolkit-mark-v1.png +0 -0
- package/skills/depurar/SKILL.md +0 -69
- package/skills/depurar/agents/openai.yaml +0 -7
- package/skills/depurar/references/tecnicas-de-diagnostico.md +0 -37
- package/skills/desarrollo-con-pruebas/SKILL.md +0 -55
- package/skills/desarrollo-con-pruebas/agents/openai.yaml +0 -7
- package/skills/desarrollo-con-pruebas/references/buenas-pruebas.md +0 -33
- package/skills/estado/SKILL.md +0 -41
- package/skills/programar/SKILL.md +0 -19
- package/skills/spec-driven-development/SKILL.md +0 -48
- package/skills/spec-driven-development/agents/openai.yaml +0 -7
- package/skills/spec-driven-development/references/spec-template.md +0 -55
- package/skills/spec-driven-development/references/workflow.md +0 -70
- package/src/toolkit-tui.js +0 -961
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { resolveCliInvocation } from './cli-executable.js';
|
|
2
|
+
|
|
3
|
+
const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
4
|
+
export async function buildRevisionInvocation({
|
|
5
|
+
client,
|
|
6
|
+
model,
|
|
7
|
+
effort,
|
|
8
|
+
root,
|
|
9
|
+
environment = process.env,
|
|
10
|
+
resolve = resolveCliInvocation,
|
|
11
|
+
}) {
|
|
12
|
+
if (
|
|
13
|
+
!['claude', 'codex', 'antigravity'].includes(client) ||
|
|
14
|
+
typeof model !== 'string' ||
|
|
15
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/u.test(model) ||
|
|
16
|
+
!EFFORTS.has(effort)
|
|
17
|
+
)
|
|
18
|
+
throw new Error('Revisor, modelo o esfuerzo inválido.');
|
|
19
|
+
const cli = await resolve({ name: client === 'antigravity' ? 'agy' : client, environment });
|
|
20
|
+
let args;
|
|
21
|
+
if (client === 'codex')
|
|
22
|
+
args = [
|
|
23
|
+
'exec',
|
|
24
|
+
'--model',
|
|
25
|
+
model,
|
|
26
|
+
'-c',
|
|
27
|
+
`model_reasoning_effort="${effort}"`,
|
|
28
|
+
'-c',
|
|
29
|
+
'approval_policy="never"',
|
|
30
|
+
'-c',
|
|
31
|
+
'mcp_servers={}',
|
|
32
|
+
'--disable',
|
|
33
|
+
'apps',
|
|
34
|
+
'--sandbox',
|
|
35
|
+
'read-only',
|
|
36
|
+
'--json',
|
|
37
|
+
'--ignore-user-config',
|
|
38
|
+
'-C',
|
|
39
|
+
root,
|
|
40
|
+
'-',
|
|
41
|
+
];
|
|
42
|
+
else if (client === 'claude')
|
|
43
|
+
args = [
|
|
44
|
+
'-p',
|
|
45
|
+
'--model',
|
|
46
|
+
model,
|
|
47
|
+
'--effort',
|
|
48
|
+
effort,
|
|
49
|
+
'--strict-mcp-config',
|
|
50
|
+
'--allowedTools',
|
|
51
|
+
'Read,Glob,Grep',
|
|
52
|
+
'--disallowedTools',
|
|
53
|
+
'Edit,Write,NotebookEdit,Bash',
|
|
54
|
+
'--output-format',
|
|
55
|
+
'json',
|
|
56
|
+
'--no-session-persistence',
|
|
57
|
+
];
|
|
58
|
+
else
|
|
59
|
+
args = [
|
|
60
|
+
'--print=',
|
|
61
|
+
'--input-format',
|
|
62
|
+
'stream-json',
|
|
63
|
+
'--output-format',
|
|
64
|
+
'stream-json',
|
|
65
|
+
'--model',
|
|
66
|
+
model,
|
|
67
|
+
'--effort',
|
|
68
|
+
effort,
|
|
69
|
+
'--mode',
|
|
70
|
+
'plan',
|
|
71
|
+
'--sandbox',
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
executable: cli.executable,
|
|
75
|
+
args: [...cli.argsPrefix, ...args],
|
|
76
|
+
env: { ...environment },
|
|
77
|
+
cwd: root,
|
|
78
|
+
inputFormat: client === 'antigravity' ? 'stream-json' : 'text',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const count = (n) => (Number.isSafeInteger(n) && n >= 0 ? n : null);
|
|
83
|
+
const usageOf = (u = {}) => ({
|
|
84
|
+
inputTokens: count(u.input_tokens ?? u.inputTokens),
|
|
85
|
+
outputTokens: count(u.output_tokens ?? u.outputTokens),
|
|
86
|
+
cachedInputTokens: count(
|
|
87
|
+
u.cached_input_tokens ?? u.cache_read_tokens ?? u.cache_read_input_tokens ?? u.cacheReadInputTokens,
|
|
88
|
+
),
|
|
89
|
+
cacheCreationTokens: count(u.cache_creation_input_tokens ?? u.cacheCreationInputTokens),
|
|
90
|
+
thinkingTokens: count(u.thinking_tokens ?? u.output_tokens_details?.thinking_tokens ?? u.thinkingTokens),
|
|
91
|
+
});
|
|
92
|
+
const optionalText = (value) => (typeof value === 'string' && value ? value : null);
|
|
93
|
+
export function parseRevisionOutput(client, stdout) {
|
|
94
|
+
let data;
|
|
95
|
+
try {
|
|
96
|
+
if (client === 'codex') {
|
|
97
|
+
const events = stdout
|
|
98
|
+
.trim()
|
|
99
|
+
.split('\n')
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.map((line) => JSON.parse(line));
|
|
102
|
+
if (events.some((e) => ['error', 'turn.failed'].includes(e.type))) throw new Error();
|
|
103
|
+
const turn = events.findLast((e) => e.type === 'turn.completed');
|
|
104
|
+
const report = events
|
|
105
|
+
.filter((e) => e.type === 'item.completed' && e.item?.type === 'agent_message')
|
|
106
|
+
.map((e) => e.item.text)
|
|
107
|
+
.join('\n');
|
|
108
|
+
if (!report.trim()) throw new Error('El revisor no devolvió informe');
|
|
109
|
+
if (!turn) throw new Error();
|
|
110
|
+
return {
|
|
111
|
+
report,
|
|
112
|
+
executionId: optionalText(events.find((e) => e.type === 'thread.started')?.thread_id),
|
|
113
|
+
usage: usageOf(turn.usage),
|
|
114
|
+
reportedModel: null,
|
|
115
|
+
reportedEffort: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (!['claude', 'antigravity'].includes(client)) throw new Error();
|
|
119
|
+
if (client === 'antigravity' && stdout.trim().includes('\n')) {
|
|
120
|
+
const events = stdout
|
|
121
|
+
.trim()
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.map((line) => JSON.parse(line));
|
|
125
|
+
data = events.findLast((e) => e.event === 'result')?.result;
|
|
126
|
+
const init = events.find((e) => e.event === 'init')?.init;
|
|
127
|
+
if (data && init?.model) data = { ...data, model: init.model };
|
|
128
|
+
} else {
|
|
129
|
+
data = JSON.parse(stdout);
|
|
130
|
+
if (client === 'antigravity' && data.event === 'result') data = data.result;
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
!data ||
|
|
134
|
+
data.is_error ||
|
|
135
|
+
data.status === 'ERROR' ||
|
|
136
|
+
data.denied_actions?.length ||
|
|
137
|
+
data.permission_denials?.length
|
|
138
|
+
)
|
|
139
|
+
throw new Error();
|
|
140
|
+
const report = client === 'claude' ? data.result : data.response;
|
|
141
|
+
if (typeof report !== 'string' || !report.trim()) throw new Error('El revisor no devolvió informe');
|
|
142
|
+
const models = Object.entries(data.modelUsage ?? {});
|
|
143
|
+
const modelData = models.length === 1 ? models[0][1] : null;
|
|
144
|
+
return {
|
|
145
|
+
report,
|
|
146
|
+
executionId: optionalText(data.session_id ?? data.conversation_id),
|
|
147
|
+
usage: usageOf(data.usage ?? modelData ?? {}),
|
|
148
|
+
reportedModel: optionalText(
|
|
149
|
+
data.model ?? modelData?.canonicalModel ?? (models.length === 1 ? models[0][0] : null),
|
|
150
|
+
),
|
|
151
|
+
reportedEffort: optionalText(data.effort),
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error.message === 'El revisor no devolvió informe') throw error;
|
|
155
|
+
throw new Error('Salida del revisor inválida, incompleta o con acciones denegadas.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveProjectRoot } from './project-root.js';
|
|
5
|
+
|
|
6
|
+
const runGit = (args, cwd) =>
|
|
7
|
+
execFileSync('git', args, {
|
|
8
|
+
cwd,
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
11
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
12
|
+
});
|
|
13
|
+
const splitNames = (text) => String(text).split('\0').filter(Boolean);
|
|
14
|
+
|
|
15
|
+
export function enforceReviewDiffLimit(diff) {
|
|
16
|
+
const bytes = Buffer.byteLength(diff, 'utf8');
|
|
17
|
+
if (bytes > 400 * 1024) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`El diff tiene ${bytes} bytes y supera el límite de 409600 bytes. Usa --base más reciente o menos archivos para revisar por partes.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function collectRevisionContext({
|
|
25
|
+
start = process.cwd(),
|
|
26
|
+
base,
|
|
27
|
+
homeDirectory,
|
|
28
|
+
runGit: run = runGit,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const { root, git } = await resolveProjectRoot(start, { homeDirectory, runGit: run });
|
|
31
|
+
if (!git) throw new Error('La revisión necesita un repositorio Git.');
|
|
32
|
+
const resolveCommit = (ref) => {
|
|
33
|
+
if (typeof ref !== 'string' || !ref || ref.startsWith('-') || /[\0\r\n]/u.test(ref))
|
|
34
|
+
throw new Error('Base de revisión inválida.');
|
|
35
|
+
return String(run(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], root)).trim();
|
|
36
|
+
};
|
|
37
|
+
if (base === undefined) {
|
|
38
|
+
try {
|
|
39
|
+
const remote = String(run(['symbolic-ref', 'refs/remotes/origin/HEAD'], root)).trim();
|
|
40
|
+
resolveCommit(remote);
|
|
41
|
+
base = remote;
|
|
42
|
+
} catch {
|
|
43
|
+
const found = ['main', 'master'].filter((ref) => {
|
|
44
|
+
try {
|
|
45
|
+
resolveCommit(`refs/heads/${ref}`);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (found.length !== 1) throw new Error('Base principal ausente o ambigua; indica --base.');
|
|
52
|
+
base = found[0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Pin the common ancestor, excluding changes that only landed upstream.
|
|
56
|
+
const baseCommit = resolveCommit(base);
|
|
57
|
+
const headCommit = resolveCommit('HEAD');
|
|
58
|
+
const ancestors = String(run(['merge-base', '--all', baseCommit, headCommit], root))
|
|
59
|
+
.trim()
|
|
60
|
+
.split(/\s+/u)
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
if (ancestors.length !== 1) throw new Error('Base común ausente o ambigua para la revisión.');
|
|
63
|
+
const [commit] = ancestors;
|
|
64
|
+
const tracked = splitNames(run(['diff', '--no-ext-diff', '--name-only', '-z', commit, '--'], root));
|
|
65
|
+
const untracked = splitNames(run(['ls-files', '--others', '--exclude-standard', '-z'], root));
|
|
66
|
+
const changedFiles = [...new Set([...tracked, ...untracked])].sort();
|
|
67
|
+
let diff = String(run(['diff', '--no-ext-diff', '--no-textconv', commit, '--'], root));
|
|
68
|
+
enforceReviewDiffLimit(diff);
|
|
69
|
+
for (const name of untracked) {
|
|
70
|
+
const file = path.join(root, name),
|
|
71
|
+
stat = await fs.lstat(file);
|
|
72
|
+
const heading = `\nArchivo nuevo: ${JSON.stringify(name)}\n`;
|
|
73
|
+
if (stat.isSymbolicLink()) diff += `${heading}Enlace: ${await fs.readlink(file)}\n`;
|
|
74
|
+
else if (!stat.isFile()) diff += `${heading}Archivo especial: sin contenido textual\n`;
|
|
75
|
+
else {
|
|
76
|
+
if (stat.size > 1024 * 1024)
|
|
77
|
+
throw new Error('Archivo nuevo demasiado grande para revisar (máximo 1 MiB).');
|
|
78
|
+
const contents = await fs.readFile(file);
|
|
79
|
+
diff +=
|
|
80
|
+
heading + (contents.includes(0) ? 'Binario: sin diff textual\n' : contents.toString('utf8') + '\n');
|
|
81
|
+
}
|
|
82
|
+
enforceReviewDiffLimit(diff);
|
|
83
|
+
}
|
|
84
|
+
enforceReviewDiffLimit(diff);
|
|
85
|
+
return { root, base, diff, changedFiles };
|
|
86
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { acquireLock } from './install-lock.js';
|
|
6
|
+
import { readCodexProbeDenial } from './revision-provenance.js';
|
|
7
|
+
import { runRevisionProcess } from './revision-process.js';
|
|
8
|
+
import { snapshotRevision, compareRevisionSnapshots } from './revision-snapshot.js';
|
|
9
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
|
|
11
|
+
export async function readReviewerRecords(homeDirectory) {
|
|
12
|
+
const file = path.join(homeDirectory, '.nexakit', 'revisores.json');
|
|
13
|
+
try {
|
|
14
|
+
const info = await fs.lstat(file);
|
|
15
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 4 * 1024 * 1024) return {};
|
|
16
|
+
const saved = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
17
|
+
return saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {};
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function checkReviewerIsolation({
|
|
25
|
+
client,
|
|
26
|
+
version,
|
|
27
|
+
platform = process.platform,
|
|
28
|
+
isolationProfile,
|
|
29
|
+
homeDirectory,
|
|
30
|
+
runProbe,
|
|
31
|
+
acquire = acquireLock,
|
|
32
|
+
}) {
|
|
33
|
+
const identity = { client, version, platform, isolationProfile };
|
|
34
|
+
const dir = path.join(homeDirectory, '.nexakit'),
|
|
35
|
+
file = path.join(dir, 'revisores.json');
|
|
36
|
+
const key = hash(JSON.stringify(identity));
|
|
37
|
+
const matches = (record) =>
|
|
38
|
+
record && Object.entries(identity).every(([field, value]) => record.identity?.[field] === value);
|
|
39
|
+
let lock = await acquire(dir);
|
|
40
|
+
let cached;
|
|
41
|
+
try {
|
|
42
|
+
cached = (await readReviewerRecords(homeDirectory))[key];
|
|
43
|
+
} finally {
|
|
44
|
+
await lock.release();
|
|
45
|
+
}
|
|
46
|
+
if (matches(cached) && ['passed', 'failed'].includes(cached.status)) return cached;
|
|
47
|
+
|
|
48
|
+
// Model calls never hold the shared installation lock.
|
|
49
|
+
let evidence;
|
|
50
|
+
try {
|
|
51
|
+
evidence = runProbe
|
|
52
|
+
? await runProbe()
|
|
53
|
+
: { status: 'unverified', reason: 'Falta evidencia de aislamiento.' };
|
|
54
|
+
} catch {
|
|
55
|
+
evidence = { status: 'unverified', reason: 'No se pudo completar la prueba de aislamiento.' };
|
|
56
|
+
}
|
|
57
|
+
const result = {
|
|
58
|
+
identity,
|
|
59
|
+
status: ['passed', 'failed'].includes(evidence?.status) ? evidence.status : 'unverified',
|
|
60
|
+
reason: String(evidence?.reason ?? 'Sin evidencia'),
|
|
61
|
+
testedAt: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
lock = await acquire(dir);
|
|
65
|
+
try {
|
|
66
|
+
const latest = await readReviewerRecords(homeDirectory);
|
|
67
|
+
const other = latest[key];
|
|
68
|
+
// A concurrent failure always wins over approval. Preserve unrelated records.
|
|
69
|
+
if (
|
|
70
|
+
matches(other) &&
|
|
71
|
+
(other.status === 'failed' || (other.status === 'passed' && result.status !== 'failed'))
|
|
72
|
+
)
|
|
73
|
+
return other;
|
|
74
|
+
const temporary = file + '.' + randomUUID();
|
|
75
|
+
await lock.assertHeld();
|
|
76
|
+
await fs.writeFile(temporary, JSON.stringify({ ...latest, [key]: result }), { mode: 0o600, flag: 'wx' });
|
|
77
|
+
try {
|
|
78
|
+
await lock.assertHeld();
|
|
79
|
+
await fs.rename(temporary, file);
|
|
80
|
+
} finally {
|
|
81
|
+
await fs.rm(temporary, { force: true });
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
} finally {
|
|
85
|
+
await lock.release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Probes fail closed: narration alone is never accepted as a denied tool call.
|
|
90
|
+
export async function probeReviewer({
|
|
91
|
+
client,
|
|
92
|
+
invocation,
|
|
93
|
+
root,
|
|
94
|
+
homeDirectory,
|
|
95
|
+
runProcess = runRevisionProcess,
|
|
96
|
+
}) {
|
|
97
|
+
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'nexakit-isolation-'));
|
|
98
|
+
try {
|
|
99
|
+
const cwd = path.join(temp, 'cwd');
|
|
100
|
+
await fs.mkdir(cwd);
|
|
101
|
+
await fs.writeFile(path.join(cwd, 'testigo.txt'), 'ORIGINAL\n');
|
|
102
|
+
await fs.writeFile(path.join(temp, 'externo.txt'), 'ORIGINAL\n');
|
|
103
|
+
const before = await snapshotRevision(temp);
|
|
104
|
+
const actions = [
|
|
105
|
+
`crea ${path.join(cwd, 'nuevo.txt')} con una línea`,
|
|
106
|
+
`reemplaza ${path.join(temp, 'externo.txt')} por MODIFICADO`,
|
|
107
|
+
`ejecuta shell para escribir ${path.join(cwd, 'shell.txt')}`,
|
|
108
|
+
];
|
|
109
|
+
if (client === 'antigravity') actions.push('intenta usar una herramienta MCP de solo lectura');
|
|
110
|
+
for (const action of actions) {
|
|
111
|
+
const result = await runProcess(
|
|
112
|
+
{ ...invocation, cwd, args: invocation.args.map((arg) => (arg === root ? cwd : arg)) },
|
|
113
|
+
{
|
|
114
|
+
prompt: `Prueba de aislamiento autorizada. Intenta con tus herramientas: ${action}. No cambies otros archivos ni pidas permisos nuevos.`,
|
|
115
|
+
timeoutMs: 120000,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
if (compareRevisionSnapshots(before, await snapshotRevision(temp)).length)
|
|
119
|
+
return { status: 'failed', reason: 'La prueba modificó el disco.' };
|
|
120
|
+
if (result.status !== 0 || result.timedOut)
|
|
121
|
+
return { status: 'unverified', reason: 'La prueba no terminó correctamente.' };
|
|
122
|
+
let denied = false;
|
|
123
|
+
try {
|
|
124
|
+
if (client === 'claude')
|
|
125
|
+
denied = JSON.parse(result.stdout).permission_denials?.some((x) =>
|
|
126
|
+
(action.includes('shell') ? ['Bash'] : ['Write', 'Edit']).includes(x.tool_name),
|
|
127
|
+
);
|
|
128
|
+
else if (client === 'antigravity')
|
|
129
|
+
denied = result.stdout
|
|
130
|
+
.trim()
|
|
131
|
+
.split('\n')
|
|
132
|
+
.some((line) => {
|
|
133
|
+
const j = JSON.parse(line);
|
|
134
|
+
const expected = action.includes('MCP')
|
|
135
|
+
? 'mcp'
|
|
136
|
+
: action.includes('shell')
|
|
137
|
+
? 'command'
|
|
138
|
+
: 'write_file';
|
|
139
|
+
return (j.result ?? j).denied_actions?.some((x) => x.action === expected);
|
|
140
|
+
});
|
|
141
|
+
else
|
|
142
|
+
denied = result.stdout
|
|
143
|
+
.trim()
|
|
144
|
+
.split('\n')
|
|
145
|
+
.some((line) => {
|
|
146
|
+
const j = JSON.parse(line),
|
|
147
|
+
item = j.item;
|
|
148
|
+
return (
|
|
149
|
+
item?.type === 'command_execution' &&
|
|
150
|
+
item.exit_code !== 0 &&
|
|
151
|
+
/operation not permitted|permission denied|read-only/iu.test(item.aggregated_output ?? '')
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
} catch {}
|
|
155
|
+
if (!denied && client === 'codex') {
|
|
156
|
+
let executionId;
|
|
157
|
+
try {
|
|
158
|
+
executionId = result.stdout
|
|
159
|
+
.trim()
|
|
160
|
+
.split('\n')
|
|
161
|
+
.map(JSON.parse)
|
|
162
|
+
.find((e) => e.type === 'thread.started')?.thread_id;
|
|
163
|
+
} catch {}
|
|
164
|
+
denied = await readCodexProbeDenial({ executionId, homeDirectory });
|
|
165
|
+
}
|
|
166
|
+
if (!denied)
|
|
167
|
+
return {
|
|
168
|
+
status: 'unverified',
|
|
169
|
+
reason: 'No hay denegación de herramienta registrada; el relato del modelo no basta.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { status: 'passed', reason: 'Acciones denegadas con disco intacto.' };
|
|
173
|
+
} finally {
|
|
174
|
+
await fs.rm(temp, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function reviewerProfile({
|
|
179
|
+
client,
|
|
180
|
+
invocation,
|
|
181
|
+
root,
|
|
182
|
+
homeDirectory,
|
|
183
|
+
environment = process.env,
|
|
184
|
+
}) {
|
|
185
|
+
const files = {
|
|
186
|
+
claude: [
|
|
187
|
+
path.join(environment.CLAUDE_CONFIG_DIR ?? path.join(homeDirectory, '.claude'), 'settings.json'),
|
|
188
|
+
path.join(homeDirectory, '.claude.json'),
|
|
189
|
+
path.join(root, '.claude', 'settings.json'),
|
|
190
|
+
path.join(root, '.claude', 'settings.local.json'),
|
|
191
|
+
],
|
|
192
|
+
codex: [
|
|
193
|
+
path.join(environment.CODEX_HOME ?? path.join(homeDirectory, '.codex'), 'config.toml'),
|
|
194
|
+
path.join(root, '.codex', 'config.toml'),
|
|
195
|
+
],
|
|
196
|
+
antigravity: [
|
|
197
|
+
path.join(homeDirectory, '.gemini', 'antigravity-cli', 'settings.json'),
|
|
198
|
+
path.join(homeDirectory, '.gemini', 'config', 'mcp_config.json'),
|
|
199
|
+
path.join(root, '.gemini', 'settings.json'),
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
const configurations = [];
|
|
203
|
+
for (const file of files[client] ?? []) {
|
|
204
|
+
try {
|
|
205
|
+
const stat = await fs.lstat(file);
|
|
206
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024)
|
|
207
|
+
throw new Error('Configuración de permisos no verificable.');
|
|
208
|
+
configurations.push(hash(await fs.readFile(file)));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error.code !== 'ENOENT') throw error;
|
|
211
|
+
configurations.push('absent');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Only digests enter the certificate. Never read authentication files.
|
|
215
|
+
return hash(
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
executable: invocation.executable,
|
|
218
|
+
args: invocation.args.map((arg) => (arg === root ? '<root>' : arg)),
|
|
219
|
+
configurations,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn as nativeSpawn, execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const aborted = () => Object.assign(new Error('Revisión cancelada.'), { code: 'ABORT_ERR' });
|
|
5
|
+
export async function runRevisionProcess(
|
|
6
|
+
invocation,
|
|
7
|
+
{ prompt, timeoutMs = 900000, signal, spawn = nativeSpawn, maxOutputBytes = 16 * 1024 * 1024 } = {},
|
|
8
|
+
) {
|
|
9
|
+
if (signal?.aborted) throw aborted();
|
|
10
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 900000)
|
|
11
|
+
throw new Error('Tiempo de revisión inválido.');
|
|
12
|
+
const input =
|
|
13
|
+
invocation.inputFormat === 'stream-json'
|
|
14
|
+
? JSON.stringify({ event: 'user', message: { role: 'user', content: prompt } }) + '\n'
|
|
15
|
+
: prompt;
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let child,
|
|
18
|
+
failure,
|
|
19
|
+
timedOut = false,
|
|
20
|
+
total = 0,
|
|
21
|
+
killTimer,
|
|
22
|
+
timer;
|
|
23
|
+
const stdout = [],
|
|
24
|
+
stderr = [];
|
|
25
|
+
const kill = (force) => {
|
|
26
|
+
if (!child?.pid) return;
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
|
29
|
+
execFile(
|
|
30
|
+
path.join(systemRoot, 'System32', 'taskkill.exe'),
|
|
31
|
+
['/PID', String(child.pid), '/T', '/F'],
|
|
32
|
+
{ windowsHide: true },
|
|
33
|
+
() => {},
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(-child.pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e.code !== 'ESRCH') child.kill(force ? 'SIGKILL' : 'SIGTERM');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const stop = (error) => {
|
|
44
|
+
if (failure || timedOut) return;
|
|
45
|
+
if (error) failure = error;
|
|
46
|
+
else timedOut = true;
|
|
47
|
+
kill(false);
|
|
48
|
+
killTimer = setTimeout(() => kill(true), 250);
|
|
49
|
+
};
|
|
50
|
+
const onAbort = () => stop(aborted());
|
|
51
|
+
const clean = () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
clearTimeout(killTimer);
|
|
54
|
+
signal?.removeEventListener('abort', onAbort);
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
child = spawn(invocation.executable, invocation.args, {
|
|
58
|
+
cwd: invocation.cwd,
|
|
59
|
+
env: invocation.env,
|
|
60
|
+
shell: false,
|
|
61
|
+
detached: process.platform !== 'win32',
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
+
});
|
|
65
|
+
} catch (e) {
|
|
66
|
+
reject(e);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const collect = (target) => (chunk) => {
|
|
70
|
+
total += Buffer.byteLength(chunk);
|
|
71
|
+
if (total > maxOutputBytes) {
|
|
72
|
+
stop(new Error('La salida del revisor supera el límite.'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
target.push(Buffer.from(chunk));
|
|
76
|
+
};
|
|
77
|
+
child.stdout.on('data', collect(stdout));
|
|
78
|
+
child.stderr.on('data', collect(stderr));
|
|
79
|
+
child.on('error', (e) => {
|
|
80
|
+
failure = e;
|
|
81
|
+
});
|
|
82
|
+
child.stdin.on('error', (e) => {
|
|
83
|
+
if (e.code !== 'EPIPE') stop(e);
|
|
84
|
+
});
|
|
85
|
+
child.on('close', (status) => {
|
|
86
|
+
// A descendant can survive its parent; finish killing the group on cancellation.
|
|
87
|
+
if (failure || timedOut) kill(true);
|
|
88
|
+
clean();
|
|
89
|
+
if (failure) reject(failure);
|
|
90
|
+
else
|
|
91
|
+
resolve({
|
|
92
|
+
status,
|
|
93
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
94
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
95
|
+
timedOut,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
timer = setTimeout(() => stop(), timeoutMs);
|
|
99
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
100
|
+
if (signal?.aborted) onAbort();
|
|
101
|
+
child.stdin.end(input, 'utf8');
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Only the rollout whose metadata matches this execution can provide provenance.
|
|
5
|
+
async function executionEvents({ executionId, homeDirectory }) {
|
|
6
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/u.test(executionId ?? '') || !homeDirectory) return null;
|
|
7
|
+
const root = path.join(homeDirectory, '.codex', 'sessions');
|
|
8
|
+
let visited = 0;
|
|
9
|
+
const found = [];
|
|
10
|
+
async function scan(dir, depth = 0) {
|
|
11
|
+
if (depth > 4 || ++visited > 10000) return;
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
15
|
+
} catch (e) {
|
|
16
|
+
if (e.code === 'ENOENT') return;
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (entry.isSymbolicLink()) continue;
|
|
21
|
+
const full = path.join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) await scan(full, depth + 1);
|
|
23
|
+
else if (entry.isFile() && entry.name.endsWith(`-${executionId}.jsonl`)) found.push(full);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
await scan(root);
|
|
27
|
+
const matches = [];
|
|
28
|
+
for (const file of found) {
|
|
29
|
+
try {
|
|
30
|
+
if ((await fs.lstat(file)).size > 16 * 1024 * 1024) continue;
|
|
31
|
+
const events = (await fs.readFile(file, 'utf8'))
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((line) => JSON.parse(line));
|
|
35
|
+
const meta = events.find((e) => e.type === 'session_meta')?.payload;
|
|
36
|
+
if (meta?.id !== executionId || meta.originator !== 'codex_exec') continue;
|
|
37
|
+
matches.push(events);
|
|
38
|
+
} catch {
|
|
39
|
+
/* Missing or incomplete provenance is never a guessed model. */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return matches.length === 1 ? matches[0] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readRevisionProvenance(options) {
|
|
46
|
+
if (options.client !== 'codex') return null;
|
|
47
|
+
const events = await executionEvents(options);
|
|
48
|
+
const turn = events?.findLast((e) => e.type === 'turn_context')?.payload;
|
|
49
|
+
return typeof turn?.model === 'string' && turn.model
|
|
50
|
+
? {
|
|
51
|
+
model: turn.model,
|
|
52
|
+
effort: typeof turn.effort === 'string' ? turn.effort : null,
|
|
53
|
+
source: 'registro de codex',
|
|
54
|
+
}
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
export async function readCodexProbeDenial(options) {
|
|
58
|
+
const events = await executionEvents(options);
|
|
59
|
+
function denied(value, depth = 0) {
|
|
60
|
+
if (depth > 12 || value === null || value === undefined) return false;
|
|
61
|
+
if (typeof value === 'string') {
|
|
62
|
+
try {
|
|
63
|
+
return denied(JSON.parse(value), depth + 1);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(value)) return value.some((x) => denied(x, depth + 1));
|
|
69
|
+
if (typeof value === 'object') {
|
|
70
|
+
if (
|
|
71
|
+
Number.isInteger(value.exit_code) &&
|
|
72
|
+
value.exit_code !== 0 &&
|
|
73
|
+
typeof value.output === 'string' &&
|
|
74
|
+
/operation not permitted|permission denied|read-only/iu.test(value.output)
|
|
75
|
+
)
|
|
76
|
+
return true;
|
|
77
|
+
return Object.values(value).some((x) => denied(x, depth + 1));
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
events?.some(
|
|
83
|
+
(e) =>
|
|
84
|
+
e.type === 'response_item' &&
|
|
85
|
+
['function_call_output', 'custom_tool_call_output'].includes(e.payload?.type) &&
|
|
86
|
+
denied(e.payload.output),
|
|
87
|
+
) ?? false
|
|
88
|
+
);
|
|
89
|
+
}
|