copilot-operator 0.1.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/LICENSE +21 -0
- package/README.md +556 -0
- package/dist/src/api/app.module.js +18 -0
- package/dist/src/api/dataAcl.js +109 -0
- package/dist/src/api/folderPicker.js +110 -0
- package/dist/src/api/main.js +132 -0
- package/dist/src/api/operator.controller.js +975 -0
- package/dist/src/api/operator.service.js +1971 -0
- package/dist/src/api/saveToDesktop.js +157 -0
- package/dist/src/api/security.js +225 -0
- package/dist/src/api/settings.js +57 -0
- package/dist/src/cli.js +531 -0
- package/dist/src/config/layout.js +66 -0
- package/dist/src/config/lockedPolicy.js +176 -0
- package/dist/src/config/schema.js +487 -0
- package/dist/src/context/contextFiles.js +295 -0
- package/dist/src/context/desktopMirror.js +113 -0
- package/dist/src/context/projectMirror.js +364 -0
- package/dist/src/exec/authorizer.js +61 -0
- package/dist/src/exec/checks.js +285 -0
- package/dist/src/exec/confinement.js +224 -0
- package/dist/src/exec/dangerous.js +161 -0
- package/dist/src/exec/environment.js +80 -0
- package/dist/src/exec/isolation.js +166 -0
- package/dist/src/exec/network.js +242 -0
- package/dist/src/exec/policy.js +201 -0
- package/dist/src/exec/policyManifest.js +84 -0
- package/dist/src/exec/processes.js +329 -0
- package/dist/src/exec/programs.js +290 -0
- package/dist/src/exec/redaction.js +93 -0
- package/dist/src/exec/reportFile.js +154 -0
- package/dist/src/exec/runner.js +245 -0
- package/dist/src/exec/scriptFiles.js +81 -0
- package/dist/src/exec/shellExecuteTrap.js +221 -0
- package/dist/src/exec/shells.js +298 -0
- package/dist/src/exec/stepEnv.js +72 -0
- package/dist/src/exec/winps.js +17 -0
- package/dist/src/exec/workDir.js +94 -0
- package/dist/src/log/runLog.js +43 -0
- package/dist/src/orchestrator/derivedChecks.js +95 -0
- package/dist/src/orchestrator/review.js +590 -0
- package/dist/src/orchestrator/taskRunner.js +1582 -0
- package/dist/src/plan/brief.js +1785 -0
- package/dist/src/plan/importPlan.js +202 -0
- package/dist/src/plan/schema.js +483 -0
- package/dist/src/plan/script.js +1012 -0
- package/dist/src/plan/systemGuide.js +1851 -0
- package/dist/src/protocol/parser.js +224 -0
- package/dist/src/protocol/replySchema.js +207 -0
- package/dist/src/protocol/reporter.js +75 -0
- package/dist/src/protocol/reviewSchema.js +205 -0
- package/dist/src/session/compose.js +67 -0
- package/dist/src/session/debugExport.js +165 -0
- package/dist/src/session/events.js +34 -0
- package/dist/src/session/exportRecord.js +261 -0
- package/dist/src/session/exports.js +484 -0
- package/dist/src/session/model.js +22 -0
- package/dist/src/session/retention.js +34 -0
- package/dist/src/session/runName.js +36 -0
- package/dist/src/session/store.js +440 -0
- package/dist/src/session/story.js +198 -0
- package/dist/src/transport/acceptance.js +34 -0
- package/dist/src/transport/chatSession.js +73 -0
- package/dist/src/transport/copilotTransport.js +1208 -0
- package/dist/src/transport/edgeCrash.js +59 -0
- package/dist/src/transport/locators.js +261 -0
- package/dist/src/transport/profileLock.js +102 -0
- package/dist/src/util/pacing.js +89 -0
- package/dist/src/vcs/commitHygiene.js +102 -0
- package/dist/src/vcs/git.js +286 -0
- package/dist/src/vcs/taskVcs.js +343 -0
- package/dist/web/404.html +15 -0
- package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
- package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
- package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
- package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
- package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
- package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
- package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
- package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
- package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
- package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
- package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
- package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
- package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
- package/dist/web/appearance.html +15 -0
- package/dist/web/appearance.txt +26 -0
- package/dist/web/defaults.html +15 -0
- package/dist/web/defaults.txt +26 -0
- package/dist/web/history.html +15 -0
- package/dist/web/history.txt +26 -0
- package/dist/web/import.html +15 -0
- package/dist/web/import.txt +26 -0
- package/dist/web/index.html +15 -0
- package/dist/web/index.txt +26 -0
- package/dist/web/level1.html +15 -0
- package/dist/web/level1.txt +26 -0
- package/dist/web/presets.html +18 -0
- package/dist/web/presets.txt +26 -0
- package/dist/web/sessions/view.html +15 -0
- package/dist/web/sessions/view.txt +26 -0
- package/dist/web/system.html +15 -0
- package/dist/web/system.txt +26 -0
- package/package.json +111 -0
- package/prompts/level1.md +535 -0
- package/prompts/organisation.bg.md +44 -0
- package/prompts/organisation.en.md +44 -0
- package/prompts/persona.bg.md +17 -0
- package/prompts/persona.en.md +17 -0
- package/prompts/review1.md +277 -0
- package/prompts/work.bg.md +19 -0
- package/prompts/work.en.md +19 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate a task has to pass before "done" is accepted.
|
|
3
|
+
*
|
|
4
|
+
* Until now a task ended when Copilot said it had ended. The `expected` line of a plan was
|
|
5
|
+
* text: it travelled to the chat, it was read, and nothing ever compared it to the machine.
|
|
6
|
+
* A check is that same expectation written so this runner can decide it for itself — run a
|
|
7
|
+
* command and look at the exit code, look for a string in the output, look for a file — and
|
|
8
|
+
* every kind here was chosen for one reason: a computer can answer it without judgement.
|
|
9
|
+
*
|
|
10
|
+
* That constraint is the whole design. This project drives a language model; it does not have
|
|
11
|
+
* one of its own, and a check whose answer needs an opinion could only be faked. So the
|
|
12
|
+
* judgement stays where it belongs, with whoever wrote the check, and what happens afterwards
|
|
13
|
+
* is mechanical: all pass and the task is done, one fails and the failures go back to the chat
|
|
14
|
+
* as a report, exactly like the output of a step, for Copilot to fix and try again.
|
|
15
|
+
*/
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { isWithin } from './confinement.js';
|
|
20
|
+
import { runStep } from './runner.js';
|
|
21
|
+
import { resolveShell } from './shells.js';
|
|
22
|
+
import { repoState, workingTreePaths } from '../vcs/git.js';
|
|
23
|
+
import { findSuspicious, suspiciousDetail } from '../vcs/commitHygiene.js';
|
|
24
|
+
/**
|
|
25
|
+
* The check the runner adds on its own whenever it is about to commit.
|
|
26
|
+
*
|
|
27
|
+
* It is a constant rather than something a plan writes because it is not a claim about the
|
|
28
|
+
* work: it is what a commit should never carry, whatever the task was.
|
|
29
|
+
*/
|
|
30
|
+
export const COMMIT_CLEAN_CHECK = { name: 'nothing installed, built, logged or secret is committed', expect: 'commit-clean' };
|
|
31
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
32
|
+
/** How much of a command's output travels back to the chat with a failure. */
|
|
33
|
+
const OUTPUT_KEPT = 4000;
|
|
34
|
+
function shorten(text, max = OUTPUT_KEPT) {
|
|
35
|
+
const trimmed = text.trim();
|
|
36
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}\n… (${trimmed.length - max} more characters)` : trimmed;
|
|
37
|
+
}
|
|
38
|
+
/** Whether a check needs a command run, which decides what it can be asked about. */
|
|
39
|
+
function needsCommand(check) {
|
|
40
|
+
return check.expect === 'exit-zero' || check.expect === 'exit-nonzero' || check.expect.startsWith('output-');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One check, decided.
|
|
44
|
+
*
|
|
45
|
+
* A check that cannot be evaluated — no command where one is needed, a command the policy
|
|
46
|
+
* refuses, a file it cannot read — counts as failed rather than as passed. The alternative is
|
|
47
|
+
* a gate that opens when it breaks, which is the one behaviour a gate must never have.
|
|
48
|
+
*/
|
|
49
|
+
export async function runCheck(check, index, opts) {
|
|
50
|
+
const fail = (detail, extra = {}) => ({ check, passed: false, detail, ...extra });
|
|
51
|
+
const pass = (detail, extra = {}) => ({ check, passed: true, detail, ...extra });
|
|
52
|
+
if (check.expect === 'commit-clean') {
|
|
53
|
+
const dir = (opts.repoDir ?? '').trim();
|
|
54
|
+
if (!dir)
|
|
55
|
+
return pass('no repository is set for this session, so nothing is committed');
|
|
56
|
+
const state = await repoState(dir).catch(() => null);
|
|
57
|
+
if (!state?.isRepo)
|
|
58
|
+
return pass(`${dir} is not a git repository, so nothing is committed`);
|
|
59
|
+
// File by file, not folder by folder: a new `api/` with node_modules inside is one line to
|
|
60
|
+
// `git status` and thousands of files to a commit.
|
|
61
|
+
const found = findSuspicious(await workingTreePaths(dir));
|
|
62
|
+
return found.length === 0
|
|
63
|
+
? pass('nothing in the working tree looks like tool output or secrets')
|
|
64
|
+
: fail(suspiciousDetail(found), { output: found.map((s) => `${s.path}\t${s.reason}`).join('\n') });
|
|
65
|
+
}
|
|
66
|
+
/*
|
|
67
|
+
* Where this check really runs, decided once. The command runs here and a file check's relative
|
|
68
|
+
* path is resolved against it — the same folder for both, so the path confined is the path read.
|
|
69
|
+
* A relative `file` used to be read relative to wherever the runner itself was started, which is
|
|
70
|
+
* this bot's own checkout: it pointed nowhere useful, and it would have made the path checked and
|
|
71
|
+
* the path read two different paths.
|
|
72
|
+
*/
|
|
73
|
+
const cwd = resolve(opts.cwd, (check.cwd ?? '').trim() || '.');
|
|
74
|
+
if (opts.roots && opts.roots.length > 0 && !isWithin(cwd, opts.roots)) {
|
|
75
|
+
return fail(`the check was refused before it ran: its working folder ${cwd} is outside the project folders (${opts.roots.join(', ')})`, { refusedBeforeRunning: true });
|
|
76
|
+
}
|
|
77
|
+
if (needsCommand(check)) {
|
|
78
|
+
const command = (check.run ?? '').trim();
|
|
79
|
+
if (!command)
|
|
80
|
+
return fail('this check asks about a command but names none');
|
|
81
|
+
/*
|
|
82
|
+
* Which shell this check runs in, asked of the same place a task step asks.
|
|
83
|
+
*
|
|
84
|
+
* A check used to fall back to `pwsh` here on its own, which is how a machine without
|
|
85
|
+
* PowerShell 7 came to fail every check with a spawn error while the same commands ran
|
|
86
|
+
* happily in `cmd`. It is resolved before the deny list rather than inside `runStep`
|
|
87
|
+
* because the gate below is told which shell it is screening for, and because a check that
|
|
88
|
+
* cannot run at all should say so without a process being started.
|
|
89
|
+
*/
|
|
90
|
+
const chosen = resolveShell(check.shell ?? opts.defaultShell);
|
|
91
|
+
if (!chosen.ok)
|
|
92
|
+
return fail(chosen.problem.message, { environmentProblem: chosen.problem });
|
|
93
|
+
const { shell, path: shellPath } = chosen.resolved;
|
|
94
|
+
const refused = opts.deny?.(command, shell, cwd);
|
|
95
|
+
if (refused)
|
|
96
|
+
return fail(`the command was refused before it ran: ${refused}`, { refusedBeforeRunning: true });
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await runStep({
|
|
100
|
+
id: 900 + index,
|
|
101
|
+
// What the check asked for, or the standing default when it asked for nothing: the
|
|
102
|
+
// runner resolves it again from the same inventory, so the two cannot disagree, and
|
|
103
|
+
// the result then records what was wanted as well as what ran.
|
|
104
|
+
shell: check.shell ?? opts.defaultShell,
|
|
105
|
+
command,
|
|
106
|
+
cwd,
|
|
107
|
+
hardTimeoutMs: opts.timeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS,
|
|
108
|
+
logPath: join(opts.logDir, `check-${index + 1}.txt`),
|
|
109
|
+
passEnv: opts.passEnv,
|
|
110
|
+
}, { signal: opts.signal, tracker: opts.tracker });
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return fail(`the check could not be run: ${e.message}`);
|
|
114
|
+
}
|
|
115
|
+
const output = shorten(`${result.stdout}${result.stderr ? `\n${result.stderr}` : ''}`);
|
|
116
|
+
const seen = { exitCode: result.exitCode, output, shell: result.shell, shellPath: result.shellPath };
|
|
117
|
+
// The shell was there when the run began and would not start when it was wanted. Nothing
|
|
118
|
+
// about the work has been learned, so this is reported as what it is rather than as a
|
|
119
|
+
// command that failed.
|
|
120
|
+
if (result.shellProblem) {
|
|
121
|
+
return fail(result.shellProblem.message, { ...seen, environmentProblem: result.shellProblem });
|
|
122
|
+
}
|
|
123
|
+
if (result.outcome !== 'completed') {
|
|
124
|
+
return fail(`the command ended ${result.outcome} rather than finishing`, seen);
|
|
125
|
+
}
|
|
126
|
+
switch (check.expect) {
|
|
127
|
+
case 'exit-zero':
|
|
128
|
+
return result.exitCode === 0
|
|
129
|
+
? pass('the command succeeded', seen)
|
|
130
|
+
: fail(`expected the command to succeed, but it exited ${result.exitCode}`, seen);
|
|
131
|
+
case 'exit-nonzero':
|
|
132
|
+
return result.exitCode !== 0
|
|
133
|
+
? pass(`the command failed, as expected (exit ${result.exitCode})`, seen)
|
|
134
|
+
: fail('expected the command to fail, but it succeeded', seen);
|
|
135
|
+
case 'output-contains':
|
|
136
|
+
return output.includes(check.value ?? '')
|
|
137
|
+
? pass(`the output contains "${check.value}"`, seen)
|
|
138
|
+
: fail(`expected the output to contain "${check.value}", and it does not`, seen);
|
|
139
|
+
case 'output-omits':
|
|
140
|
+
return output.includes(check.value ?? '')
|
|
141
|
+
? fail(`expected the output not to contain "${check.value}", but it does`, seen)
|
|
142
|
+
: pass(`the output does not contain "${check.value}"`, seen);
|
|
143
|
+
case 'output-matches': {
|
|
144
|
+
let re;
|
|
145
|
+
try {
|
|
146
|
+
re = new RegExp(check.value ?? '', 'm');
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
return fail(`the pattern is not a valid regular expression: ${e.message}`, seen);
|
|
150
|
+
}
|
|
151
|
+
return re.test(output)
|
|
152
|
+
? pass(`the output matches /${check.value}/`, seen)
|
|
153
|
+
: fail(`expected the output to match /${check.value}/, and it does not`, seen);
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return fail(`unknown check kind "${check.expect}"`, seen);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const named = (check.file ?? '').trim();
|
|
160
|
+
if (!named)
|
|
161
|
+
return fail('this check asks about a file but names none');
|
|
162
|
+
const file = resolve(cwd, named);
|
|
163
|
+
if (opts.roots && opts.roots.length > 0 && !isWithin(file, opts.roots)) {
|
|
164
|
+
return fail(`the check was refused before it ran: ${file} is outside the project folders (${opts.roots.join(', ')})`, { refusedBeforeRunning: true });
|
|
165
|
+
}
|
|
166
|
+
switch (check.expect) {
|
|
167
|
+
case 'file-exists':
|
|
168
|
+
return existsSync(file) ? pass(`${file} exists`) : fail(`expected ${file} to exist, and it does not`);
|
|
169
|
+
case 'file-missing':
|
|
170
|
+
return existsSync(file) ? fail(`expected ${file} not to exist, but it does`) : pass(`${file} does not exist`);
|
|
171
|
+
case 'file-contains': {
|
|
172
|
+
if (!existsSync(file))
|
|
173
|
+
return fail(`expected ${file} to contain "${check.value}", but the file does not exist`);
|
|
174
|
+
let body;
|
|
175
|
+
try {
|
|
176
|
+
body = await readFile(file, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
return fail(`${file} could not be read: ${e.message}`);
|
|
180
|
+
}
|
|
181
|
+
return body.includes(check.value ?? '')
|
|
182
|
+
? pass(`${file} contains "${check.value}"`)
|
|
183
|
+
: fail(`expected ${file} to contain "${check.value}", and it does not`, { output: shorten(body, 1500) });
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return fail(`unknown check kind "${check.expect}"`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Every check of a task, in order. They all run: a report of one failure at a time is slow. */
|
|
190
|
+
export async function runChecks(checks, opts) {
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const [i, check] of checks.entries()) {
|
|
193
|
+
if (opts.signal?.aborted) {
|
|
194
|
+
out.push({ check, passed: false, detail: 'the operator stopped the run before this check ran' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
out.push(await runCheck(check, i, opts));
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether a round of checks was decided by the machine rather than by the work.
|
|
203
|
+
*
|
|
204
|
+
* The first such problem is the answer: several checks on a machine with no PowerShell 7 all
|
|
205
|
+
* hit the same missing interpreter, and repeating one sentence per check helps nobody. A
|
|
206
|
+
* caller that gets something back here is being told that this round was not an attempt at the
|
|
207
|
+
* task, that it should not cost the task one of its rounds, and that there is nothing worth
|
|
208
|
+
* sending to a language model — an interpreter it cannot install is not a thing it can fix.
|
|
209
|
+
*/
|
|
210
|
+
export function environmentProblemIn(outcomes) {
|
|
211
|
+
return outcomes.find((o) => o.environmentProblem)?.environmentProblem ?? null;
|
|
212
|
+
}
|
|
213
|
+
/** A short line per check, for the live log and the task record. */
|
|
214
|
+
export function describeCheck(check) {
|
|
215
|
+
const what = check.expect === 'commit-clean' ? "the repository's uncommitted files" : needsCommand(check) ? (check.run ?? '') : (check.file ?? '');
|
|
216
|
+
return `${check.name}: ${check.expect}${check.value ? ` "${check.value}"` : ''} — ${what}`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* What Copilot is told when checks fail.
|
|
220
|
+
*
|
|
221
|
+
* Written as an instruction rather than as a complaint: it says what was asked, what happened,
|
|
222
|
+
* and that the task is not over. The full output is attached as a file by the caller, the same
|
|
223
|
+
* way step output is, because a chat message is the wrong place for a compiler's opinion.
|
|
224
|
+
*/
|
|
225
|
+
export function failureMessage(outcomes, round, maxRounds) {
|
|
226
|
+
const failed = outcomes.filter((o) => !o.passed);
|
|
227
|
+
const lines = [
|
|
228
|
+
'## The task is not finished yet',
|
|
229
|
+
'',
|
|
230
|
+
`You reported the task as done, but ${failed.length} of the ${outcomes.length} checks set for it did not pass.`,
|
|
231
|
+
'These checks are run by the runner, not by you, and they are what decides whether this task is over.',
|
|
232
|
+
'',
|
|
233
|
+
];
|
|
234
|
+
for (const [i, o] of failed.entries()) {
|
|
235
|
+
lines.push(`### ${i + 1}. ${o.check.name}`);
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push(o.check.expect === 'commit-clean'
|
|
238
|
+
? '- What was required: nothing in the commit that is installed, built, logged or secret'
|
|
239
|
+
: `- What was required: \`${o.check.expect}\`${o.check.value ? ` of "${o.check.value}"` : ''}`);
|
|
240
|
+
if (o.check.run)
|
|
241
|
+
lines.push(`- Command: \`${o.check.run}\``);
|
|
242
|
+
if (o.check.file)
|
|
243
|
+
lines.push(`- File: \`${o.check.file}\``);
|
|
244
|
+
lines.push(`- What happened: ${o.detail}`);
|
|
245
|
+
if (o.exitCode !== undefined)
|
|
246
|
+
lines.push(`- Exit code: ${o.exitCode}`);
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
249
|
+
const passed = outcomes.filter((o) => o.passed);
|
|
250
|
+
if (passed.length > 0) {
|
|
251
|
+
lines.push(`The other ${passed.length} check(s) passed: ${passed.map((o) => o.check.name).join(', ')}.`);
|
|
252
|
+
lines.push('');
|
|
253
|
+
}
|
|
254
|
+
lines.push(`Fix what is failing and continue. Send steps as usual; do not report the task as done again until you ` +
|
|
255
|
+
`have reason to believe these checks will pass. This is attempt ${round} of ${maxRounds}: after that the ` +
|
|
256
|
+
'task is closed as failed and the operator reads it.');
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
/** The same, as the plain text file that travels with the message. */
|
|
260
|
+
export function failureReport(outcomes) {
|
|
261
|
+
const parts = ['CHECKS THAT DID NOT PASS', '='.repeat(60), ''];
|
|
262
|
+
for (const o of outcomes.filter((x) => !x.passed)) {
|
|
263
|
+
parts.push(`CHECK : ${o.check.name}`);
|
|
264
|
+
parts.push(`EXPECT: ${o.check.expect}${o.check.value ? ` "${o.check.value}"` : ''}`);
|
|
265
|
+
if (o.check.run)
|
|
266
|
+
parts.push(`RUN : ${o.check.run}`);
|
|
267
|
+
if (o.check.file)
|
|
268
|
+
parts.push(`FILE : ${o.check.file}`);
|
|
269
|
+
parts.push(`RESULT: ${o.detail}`);
|
|
270
|
+
if (o.exitCode !== undefined)
|
|
271
|
+
parts.push(`EXIT : ${o.exitCode}`);
|
|
272
|
+
if (o.output) {
|
|
273
|
+
parts.push('OUTPUT:');
|
|
274
|
+
parts.push(o.output);
|
|
275
|
+
}
|
|
276
|
+
parts.push('', '-'.repeat(60), '');
|
|
277
|
+
}
|
|
278
|
+
const passed = outcomes.filter((x) => x.passed);
|
|
279
|
+
if (passed.length > 0) {
|
|
280
|
+
parts.push('CHECKS THAT PASSED', '-'.repeat(60));
|
|
281
|
+
for (const o of passed)
|
|
282
|
+
parts.push(`- ${o.check.name}: ${o.detail}`);
|
|
283
|
+
}
|
|
284
|
+
return parts.join('\n');
|
|
285
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Every other gate in this runner is about *what* a command is: a technique on the refused list, a
|
|
5
|
+
* program not on the allowlist. None of them knew *where* a command reached. A step could read the
|
|
6
|
+
* operator's SSH key, write into their AppData, delete from `C:\Windows`, change a service or open
|
|
7
|
+
* the firewall, and every one of those passed — cmdlets are not programs, so the allowlist never
|
|
8
|
+
* saw them, and nothing anywhere knew what "the project" was. Probed before this was written:
|
|
9
|
+
* of eighteen operations reaching outside the project, four were stopped, and those four only
|
|
10
|
+
* because `netsh`, `setx` and `winget` happen not to be on the program list.
|
|
11
|
+
*
|
|
12
|
+
* The rule the operator set is short: this runner works in the project folders, and nowhere else.
|
|
13
|
+
* The project folders are the session's own and every folder registered in Settings — a test
|
|
14
|
+
* suite legitimately starts the application from the repository next to it, and a plan across a
|
|
15
|
+
* front end, a back end and its tests is one piece of work. Everything outside them belongs to the
|
|
16
|
+
* machine and the person who owns it, and none of it is the task's business.
|
|
17
|
+
*
|
|
18
|
+
* Four things are refused, all by reading the command line:
|
|
19
|
+
*
|
|
20
|
+
* paths outside an absolute path, a UNC share, `..` that climbs out, a location built from
|
|
21
|
+
* the operator's profile or the system (`~`, `$env:APPDATA`, `%TEMP%`…).
|
|
22
|
+
* Reads as well as writes: what a step prints goes to the chat, so reading
|
|
23
|
+
* a key outside the project is sending it there.
|
|
24
|
+
* the registry and the other providers that are not the file system. The project has no
|
|
25
|
+
* business in any of them, reading or writing.
|
|
26
|
+
* the machine services, the network and the firewall, local users, the clock, Windows
|
|
27
|
+
* features, disks, machine-wide modules. Management of the computer.
|
|
28
|
+
* global installs `npm -g`, `dotnet tool -g`, `cargo install`, `go install`, `pip install`
|
|
29
|
+
* into the machine's own Python. The same tool installed into the project
|
|
30
|
+
* (`npm install -D`, a `.venv`) is the ordinary way and stays open.
|
|
31
|
+
*
|
|
32
|
+
* What this is not, and it has to be said where somebody will read it: a boundary. It reads what a
|
|
33
|
+
* step *says*, which is exactly what the operator asked for — whatever the chat proposes that
|
|
34
|
+
* reaches outside the project is not run. It cannot see what a program does once it is running:
|
|
35
|
+
* `node build.js`, `npm run x` and a test suite can each read and write anywhere the account can,
|
|
36
|
+
* and a path assembled from variables at run time is invisible here. Confinement that holds against
|
|
37
|
+
* that is the operating system's to give — a separate Windows account whose file permissions reach
|
|
38
|
+
* the project folders and nothing of the operator's — and the README says how.
|
|
39
|
+
*/
|
|
40
|
+
import { win32 } from 'node:path';
|
|
41
|
+
function normalise(p) {
|
|
42
|
+
return win32.resolve(p).replace(/[\\/]+$/, '').toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether a path lies within one of the roots. By path segments, not by string prefix: a root of
|
|
46
|
+
* `C:\Projects\calc` must not admit `C:\Projects\calculator-evil`, which starts with the same
|
|
47
|
+
* characters and is somewhere else entirely.
|
|
48
|
+
*/
|
|
49
|
+
export function isWithin(path, roots) {
|
|
50
|
+
const target = normalise(path);
|
|
51
|
+
return roots.some((r) => {
|
|
52
|
+
if (!r.trim())
|
|
53
|
+
return false;
|
|
54
|
+
const root = normalise(r);
|
|
55
|
+
return target === root || target.startsWith(`${root}\\`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Roots as a set: resolved, de-duplicated, empty ones dropped. */
|
|
59
|
+
export function projectRoots(folders) {
|
|
60
|
+
const seen = new Map();
|
|
61
|
+
for (const f of folders) {
|
|
62
|
+
const t = (f ?? '').trim();
|
|
63
|
+
if (!t)
|
|
64
|
+
continue;
|
|
65
|
+
const abs = win32.resolve(t);
|
|
66
|
+
seen.set(normalise(abs), abs);
|
|
67
|
+
}
|
|
68
|
+
return [...seen.values()];
|
|
69
|
+
}
|
|
70
|
+
// --- what gets read out of a command line -------------------------------------------------
|
|
71
|
+
/** Quoted strings, with their content. The content of either kind is searched for paths. */
|
|
72
|
+
const QUOTED = /(["'])((?:(?!\1)[^\r\n])*)\1/g;
|
|
73
|
+
/** A drive-absolute path, `C:\x` or `C:/x`, not glued to a word, a slash or a dot. */
|
|
74
|
+
const DRIVE_BARE = /(?<![\w/\\.])([A-Za-z]:[\\/][^\s"'`;|&<>,(){}]*)/g;
|
|
75
|
+
/** Where a drive-absolute path starts, inside quoted text that may hold several. */
|
|
76
|
+
const DRIVE_START = /(?<![\w/\\.])[A-Za-z]:[\\/]/g;
|
|
77
|
+
/** A share: `\\host\share`, and the device forms `\\?\` and `\\.\`. */
|
|
78
|
+
const UNC = /(?:^|[^\\])(\\\\(?:[?.]\\|[A-Za-z0-9._$-]+\\[A-Za-z0-9._$ -]))/;
|
|
79
|
+
/** A path written as a file URI, which reads a local file through a web cmdlet. */
|
|
80
|
+
const FILE_URI = /\bfile:\/{2,3}([A-Za-z]:[\\/][^\s"'`;|&<>]*)/gi;
|
|
81
|
+
/**
|
|
82
|
+
* Locations that belong to the operator or to the system, however they are spelled. Kept to the
|
|
83
|
+
* location-bearing names: `$env:PATH`, `$env:NODE_ENV` and the rest are not places and stay open.
|
|
84
|
+
*/
|
|
85
|
+
const LOCATION_VARS = 'USERPROFILE|HOMEDRIVE|HOMEPATH|HOMESHARE|HOME|APPDATA|LOCALAPPDATA|TEMP|TMP|WINDIR|SystemRoot|SystemDrive|' +
|
|
86
|
+
'ProgramFiles(?:\\(x86\\))?|ProgramW6432|ProgramData|ALLUSERSPROFILE|PUBLIC|OneDrive\\w*|CommonProgramFiles\\w*';
|
|
87
|
+
const ENV_LOCATION = new RegExp(`\\$\\{?env:(${LOCATION_VARS})\\}?|%(${LOCATION_VARS})%`, 'i');
|
|
88
|
+
const HOME_VAR = /\$HOME\b/i;
|
|
89
|
+
/** `~` as the start of a path, and not the `~` of `HEAD~1` or `@~1.2`. */
|
|
90
|
+
const TILDE = /(?:^|[\s"'=(,])~(?=[\\/]|$|[\s"'`;|&)])/;
|
|
91
|
+
/** Changing to the root of the current drive, which needs no drive letter to leave the project. */
|
|
92
|
+
const CD_TO_ROOT = /(?:^|[\s;|&({])(?:Set-Location|Push-Location|cd|chdir|sl|pushd)\s+["']?[\\/]["']?(?=$|[\s;|&)}])/i;
|
|
93
|
+
/** A path segment that climbs: `..` on its own, or between separators. */
|
|
94
|
+
const CLIMBS = /(?:^|[\\/])\.\.(?:$|[\\/])/;
|
|
95
|
+
/** Every whitespace- or separator-delimited token, quoted content split the same way. */
|
|
96
|
+
function tokens(command) {
|
|
97
|
+
const out = [];
|
|
98
|
+
const rest = command.replace(QUOTED, (_whole, _q, inner) => {
|
|
99
|
+
out.push(...inner.split(/\s+/));
|
|
100
|
+
return ' ';
|
|
101
|
+
});
|
|
102
|
+
out.push(...rest.split(/[\s;|&<>(){},]+/));
|
|
103
|
+
// An argument written `name=value` or `-Name:value` carries its path after the separator.
|
|
104
|
+
return out
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.flatMap((t) => {
|
|
107
|
+
const m = /^-{1,2}[\w-]+[:=](.+)$/.exec(t) ?? /^[\w.-]+=(.+)$/.exec(t);
|
|
108
|
+
return m ? [t, m[1]] : [t];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** Every drive-absolute path in a command, quoted ones whole even when they hold spaces. */
|
|
112
|
+
function drivePaths(command) {
|
|
113
|
+
const found = [];
|
|
114
|
+
const rest = command.replace(QUOTED, (whole, _q, inner) => {
|
|
115
|
+
// A quoted string may carry several paths — `cmd /c "copy C:\a C:\b"` — so each one runs from
|
|
116
|
+
// where it starts to where the next begins, rather than the first swallowing the rest.
|
|
117
|
+
const starts = [...inner.matchAll(DRIVE_START)].map((m) => m.index ?? 0);
|
|
118
|
+
starts.forEach((start, i) => found.push(inner.slice(start, starts[i + 1] ?? inner.length).trim()));
|
|
119
|
+
return ' '.repeat(whole.length);
|
|
120
|
+
});
|
|
121
|
+
for (const m of rest.matchAll(DRIVE_BARE))
|
|
122
|
+
found.push(m[1]);
|
|
123
|
+
for (const m of command.matchAll(FILE_URI))
|
|
124
|
+
found.push(m[1]);
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
// --- the machine, as opposed to the project -----------------------------------------------
|
|
128
|
+
const PROVIDERS = /\b(?:HKLM|HKCU|HKCR|HKU|HKCC):|\bRegistry::|\bCert:\\|\bWSMan:\\/i;
|
|
129
|
+
/** Cmdlet families that manage the computer. Each entry is a reason a person would recognise. */
|
|
130
|
+
const MACHINE = [
|
|
131
|
+
{ what: 'services', pattern: /\b(?:Get|Set|New|Remove|Start|Stop|Restart|Suspend|Resume)-Service\b/i },
|
|
132
|
+
{ what: 'the network and the firewall', pattern: /\b\w+-Net(?:Firewall\w*|IPAddress|IPConfiguration|IPInterface|Adapter\w*|Route|NeighborCache|ConnectionProfile|Nat\w*|Qos\w*)\b|\b\w+-DnsClient\w*\b/i },
|
|
133
|
+
{ what: 'local users and groups', pattern: /\b\w+-Local(?:User|Group|GroupMember)\b/i },
|
|
134
|
+
{ what: 'the clock, the locale and the time zone', pattern: /\bSet-(?:Date|TimeZone|Culture|WinSystemLocale|WinUserLanguageList|WinHomeLocation)\b/i },
|
|
135
|
+
{ what: 'Windows features and packages', pattern: /\b\w+-(?:WindowsOptionalFeature|WindowsFeature|WindowsCapability|WindowsPackage|AppxPackage|AppxProvisionedPackage)\b/i },
|
|
136
|
+
{ what: 'machine-wide PowerShell modules', pattern: /\b(?:Install|Uninstall|Update)-(?:Module|Package|Script|PackageProvider|PSResource)\b|\b(?:Register|Set|Unregister)-PSRepository\b/i },
|
|
137
|
+
{ what: 'disks and volumes', pattern: /\b\w+-(?:Disk|PhysicalDisk|VirtualDisk|Partition|Volume|StoragePool)\b/i },
|
|
138
|
+
{ what: 'the execution policy', pattern: /\bSet-ExecutionPolicy\b/i },
|
|
139
|
+
{ what: 'printers', pattern: /\b\w+-Printer\w*\b/i },
|
|
140
|
+
{ what: 'the computer itself', pattern: /\b(?:Rename|Add|Remove|Checkpoint|Restore|Stop|Restart)-Computer\b|\b(?:Enable|Disable)-(?:PSRemoting|WSManCredSSP|ComputerRestore)\b/i },
|
|
141
|
+
{ what: 'BitLocker and the TPM', pattern: /\b\w+-(?:BitLocker\w*|Tpm\w*)\b/i },
|
|
142
|
+
{ what: 'network shares', pattern: /\b\w+-(?:SmbShare|SmbMapping|SmbServerConfiguration)\b/i },
|
|
143
|
+
];
|
|
144
|
+
/** Installs that land outside the project, and the ordinary way to do the same inside it. */
|
|
145
|
+
const GLOBAL_INSTALL = [
|
|
146
|
+
{ what: 'a global npm or pnpm install', pattern: /\b(?:npm|pnpm)(?:\.cmd)?\b[^|;&\n]*\s(?:-g|--global|--location[= ]global)(?=\s|$)/i, instead: 'install it into the project (`npm install -D <pkg>`) and run it with `npx`' },
|
|
147
|
+
{ what: 'a global yarn install', pattern: /\byarn(?:\.cmd)?\s+global\s+add\b/i, instead: 'add it to the project (`yarn add -D <pkg>`)' },
|
|
148
|
+
{ what: 'a global bun install', pattern: /\bbun(?:\.exe)?\b[^|;&\n]*\s(?:-g|--global)(?=\s|$)/i, instead: 'add it to the project (`bun add -d <pkg>`)' },
|
|
149
|
+
{ what: 'a global .NET tool', pattern: /\bdotnet(?:\.exe)?\s+tool\s+(?:install|update)\b[^|;&\n]*\s(?:-g|--global)(?=\s|$)/i, instead: 'use a local tool manifest (`dotnet new tool-manifest`, then `dotnet tool install <tool>`)' },
|
|
150
|
+
{ what: '`cargo install`, which installs into the user profile', pattern: /\bcargo(?:\.exe)?\s+install\b/i, instead: 'add it as a dependency of the project' },
|
|
151
|
+
{ what: '`go install`, which installs outside the module', pattern: /\bgo(?:\.exe)?\s+install\b/i, instead: 'run it with `go run <pkg>@<version>` from the project' },
|
|
152
|
+
];
|
|
153
|
+
/**
|
|
154
|
+
* `pip install` into whatever Python is first on the PATH — the machine's, not the project's.
|
|
155
|
+
* Allowed when the interpreter or pip is itself a path inside the project (a `.venv`), which the
|
|
156
|
+
* path check then confirms, or when the install is aimed at a folder with `--target`/`--prefix`.
|
|
157
|
+
*/
|
|
158
|
+
function pipRefusal(command) {
|
|
159
|
+
const m = /(?:^|[\s;|&(])(["']?)([^\s"';|&()]*?)(pip3?|python3?|py)(?:\.exe)?\1\b[^|;&\n]*?\binstall\b[^|;&\n]*/i.exec(command);
|
|
160
|
+
if (!m)
|
|
161
|
+
return null;
|
|
162
|
+
const clause = m[0];
|
|
163
|
+
if (!/\b(?:pip3?)\b[^|;&\n]*\binstall\b|-m\s+pip\s+install\b/i.test(clause))
|
|
164
|
+
return null;
|
|
165
|
+
if (/\s--user\b/i.test(clause))
|
|
166
|
+
return 'refused: `pip install --user` installs into the operator\'s profile, outside the project. Create a virtual environment in the project (`python -m venv .venv`) and install into that (`.venv\\Scripts\\python -m pip install <pkg>`).';
|
|
167
|
+
const qualified = /[\\/]/.test(m[2] ?? '');
|
|
168
|
+
const aimed = /\s(?:--target|-t|--prefix|--root)(?:[\s=])/i.test(clause);
|
|
169
|
+
if (qualified || aimed)
|
|
170
|
+
return null;
|
|
171
|
+
return 'refused: `pip install` with the Python on the PATH installs into the machine\'s own Python, outside the project. Create a virtual environment in the project (`python -m venv .venv`) and install into that: `.venv\\Scripts\\python -m pip install <pkg>`.';
|
|
172
|
+
}
|
|
173
|
+
// --- the verdict ---------------------------------------------------------------------------
|
|
174
|
+
function outside(what, c) {
|
|
175
|
+
return (`refused: ${what} — this runner works only inside the project folders (${c.roots.join(', ')}), and ` +
|
|
176
|
+
'nothing outside them is the task\'s business. Use a path inside the project. If the task genuinely ' +
|
|
177
|
+
'needs something from elsewhere on the machine, say so in `notes` and end `blocked`: that is for the ' +
|
|
178
|
+
'operator to arrange, not for a step to reach for.');
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Why a command reaches outside the project, or null.
|
|
182
|
+
*
|
|
183
|
+
* Checked in order of how plainly the command says it, so the reason given is the most specific one:
|
|
184
|
+
* the machine's own settings first, then an install that lands outside, then any path at all.
|
|
185
|
+
*/
|
|
186
|
+
export function confinementRefusal(command, c) {
|
|
187
|
+
if (PROVIDERS.test(command)) {
|
|
188
|
+
return outside('the registry and the other non-file providers belong to the machine, not the project', c);
|
|
189
|
+
}
|
|
190
|
+
for (const { what, pattern } of MACHINE) {
|
|
191
|
+
if (pattern.test(command))
|
|
192
|
+
return outside(`this manages ${what}, which is the computer's, not the project's`, c);
|
|
193
|
+
}
|
|
194
|
+
for (const { what, pattern, instead } of GLOBAL_INSTALL) {
|
|
195
|
+
if (pattern.test(command)) {
|
|
196
|
+
return `refused: ${what} lands outside the project folders. ${instead[0].toUpperCase()}${instead.slice(1)}.`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const pip = pipRefusal(command);
|
|
200
|
+
if (pip)
|
|
201
|
+
return pip;
|
|
202
|
+
const env = ENV_LOCATION.exec(command);
|
|
203
|
+
if (env)
|
|
204
|
+
return outside(`\`${env[0]}\` is a location on the machine — the operator's profile or the system — not in the project`, c);
|
|
205
|
+
if (HOME_VAR.test(command) || TILDE.test(command)) {
|
|
206
|
+
return outside('`~` and `$HOME` are the operator\'s profile, not the project', c);
|
|
207
|
+
}
|
|
208
|
+
if (UNC.test(command))
|
|
209
|
+
return outside('a network share is not the project', c);
|
|
210
|
+
if (CD_TO_ROOT.test(command))
|
|
211
|
+
return outside('this changes to the root of the drive', c);
|
|
212
|
+
for (const p of drivePaths(command)) {
|
|
213
|
+
if (!isWithin(p, c.roots))
|
|
214
|
+
return outside(`\`${p}\` is outside the project`, c);
|
|
215
|
+
}
|
|
216
|
+
for (const t of tokens(command)) {
|
|
217
|
+
if (!CLIMBS.test(t))
|
|
218
|
+
continue;
|
|
219
|
+
const resolved = win32.resolve(c.cwd, t);
|
|
220
|
+
if (!isWithin(resolved, c.roots))
|
|
221
|
+
return outside(`\`${t}\` climbs out of the project to ${resolved}`, c);
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|