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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The techniques this runner refuses outright, whatever the configuration says.
3
+ *
4
+ * `execution.denyPatterns` is the operator's list: editable, sensible, and about damage — a
5
+ * wiped folder, a formatted drive, a reset repository. This is a different list and it exists
6
+ * for a different reader. A security team watching the workstation does not see intent; it sees
7
+ * `certutil.exe` decoding a base64 blob into a `.js` file in `%TEMP%` and something executing
8
+ * it, and it is right to treat that as a compromise, because that is precisely what a malware
9
+ * loader looks like. That the parent process happened to be an automation tool driven by a
10
+ * language model is not exculpatory. It is the whole problem.
11
+ *
12
+ * So these patterns are not defaults. They are not in the config schema, they cannot be edited
13
+ * away, and a `settings.json` written before they existed still gets them. The operator's list
14
+ * is applied on top; it can add, never subtract. A tool that executes commands written by a
15
+ * model has no business leaving its guard rails where the model's own output could one day
16
+ * suggest widening them.
17
+ *
18
+ * What is here, and why each one:
19
+ *
20
+ * living-off-the-land binaries `certutil`, `bitsadmin`, `mshta`, `regsvr32`, `rundll32`,
21
+ * `wscript`, `cscript`, `forfiles`, `installutil`. All are
22
+ * signed Microsoft tools with a legitimate purpose and an
23
+ * overwhelmingly illegitimate reputation; every one of them
24
+ * is on every EDR's watch list. None is needed to build, test
25
+ * or inspect a software project, which is all this runner is
26
+ * for, so refusing them costs nothing real.
27
+ * decode and run `-EncodedCommand`, `FromBase64String`, `Invoke-Expression`.
28
+ * The point of encoding a command is that a person reading the
29
+ * log cannot see what it does, and the point of this runner's
30
+ * log is that they can.
31
+ * fetch and run `DownloadString`/`DownloadFile` into execution, `iwr | iex`.
32
+ * Code that arrives at run time was never approved by anybody.
33
+ * execution out of Temp Nothing this runner legitimately does lives there: it downloads
34
+ * nothing, and the code a task runs is written into the project
35
+ * by a command step, where it can be read.
36
+ * tampering and persistence Defender exclusions, scheduled tasks, Run keys, new services.
37
+ * A build step does not need to outlive the build.
38
+ *
39
+ * Deliberately **not** here: `node`, `npm`, `npx`, `git`, `tsc`, `dotnet`, `python`. They are
40
+ * the job. A guard that stops the work is a guard that gets switched off.
41
+ *
42
+ * And the honest limit, the same one `policy.ts` states about the deny list: this is not a
43
+ * security boundary. It raises the cost of the ordinary accident and the ordinary injected
44
+ * instruction. A determined attacker with a language model to write for them will find a
45
+ * phrasing no regular expression here anticipated. The boundary is a separate Windows account
46
+ * or a sandbox, which the README recommends and which this does not replace.
47
+ */
48
+ export const DANGEROUS_TECHNIQUES = [
49
+ {
50
+ name: 'certutil',
51
+ pattern: /\bcertutil(\.exe)?\b/i,
52
+ why: 'certutil is a certificate tool routinely used to decode base64 payloads and fetch files; every EDR treats it as an indicator of compromise. Use PowerShell to read or convert files.',
53
+ },
54
+ {
55
+ name: 'bitsadmin',
56
+ pattern: /\bbitsadmin(\.exe)?\b/i,
57
+ why: 'bitsadmin transfers files in the background out of sight of the run log. This runner downloads nothing; write what is needed in the step itself.',
58
+ },
59
+ {
60
+ name: 'script-host',
61
+ pattern: /\b(wscript|cscript|mshta)(\.exe)?\b/i,
62
+ why: 'the Windows Script Host and mshta run .js, .vbs, .hta and remote pages as code. Nothing about building or testing a project needs them.',
63
+ },
64
+ {
65
+ name: 'proxy-execution',
66
+ pattern: /\b(regsvr32|rundll32|installutil|msbuild\s+[^|]*\/p:|wmic[^|]*process[^|]*call[^|]*create)\b/i,
67
+ why: 'these run code through a signed Microsoft binary so that it is not the process an observer expects. Run the tool itself instead.',
68
+ },
69
+ {
70
+ name: 'forfiles',
71
+ pattern: /\bforfiles(\.exe)?\b/i,
72
+ why: 'forfiles executes a command per matched file and is a common way to launch something under an innocuous parent. Use Get-ChildItem with a loop.',
73
+ },
74
+ {
75
+ name: 'encoded-command',
76
+ // PowerShell takes any unambiguous prefix, so `-e`, `-en` and `-enc` are the same switch as
77
+ // `-EncodedCommand`; anchoring on the whole word let `powershell -e <blob>` straight past. The
78
+ // short form is tied to a PowerShell invocation and a base64-shaped argument, so that `grep -e`
79
+ // and `node -e` are left alone.
80
+ pattern: /(-|\/)(EncodedCommand|enc)\b|\b(powershell|pwsh)(\.exe)?\b[^|\n]*\s(-|\/)e[a-z]*\s+["']?[A-Za-z0-9+\/=]{16,}/i,
81
+ why: 'an encoded command is a command nobody reading the log can see. Write it out in full.',
82
+ },
83
+ {
84
+ name: 'decode-and-run',
85
+ pattern: /FromBase64String|\bcertutil[^|\n]*-decode\b|\[System\.Text\.Encoding\][^|\n]*GetString\s*\(/i,
86
+ why: 'decoding a blob into code and running it hides what ran. If the content matters, write it to a file in the project and run that.',
87
+ },
88
+ {
89
+ name: 'invoke-expression',
90
+ pattern: /\b(Invoke-Expression|iex)\b/i,
91
+ why: 'Invoke-Expression runs a string as code, so what actually ran is decided at run time and never appears in the step. Call the command directly.',
92
+ },
93
+ {
94
+ name: 'fetch-and-run',
95
+ pattern: /(DownloadString|DownloadFile)\s*\(|\b(Invoke-WebRequest|iwr|curl|wget)\b[^|\n]*\|\s*(iex|Invoke-Expression|bash|sh|pwsh|powershell)\b/i,
96
+ why: 'code fetched at run time was approved by nobody. Write the code out in the step itself — a here-string with Set-Content into a file in the project — and run that file in the next step.',
97
+ },
98
+ {
99
+ name: 'run-from-temp',
100
+ // Any path with a Temp component, however it is spelled — `%TEMP%`, `$env:TEMP`,
101
+ // `$env:LOCALAPPDATA\\Temp`, `C:\\Windows\\Temp`, `/tmp/`. Matching the one literal spelling
102
+ // `\\AppData\\Local\\Temp` was matching one of several.
103
+ pattern: /(%TEMP%|\$env:TEMP|[\\\/]Temp[\\\/]|\/tmp\/)[^\s"';|]*\.(exe|js|jse|vbs|vbe|ps1|bat|cmd|hta|wsf|scr|dll|msi)\b/i,
104
+ why: 'this runner never executes anything out of Temp, and it downloads nothing: the code a task runs is written into the project by a command step. A binary or script run from Temp is the shape of a dropper.',
105
+ },
106
+ {
107
+ name: 'defender-tampering',
108
+ pattern: /\b(Add-MpPreference|Set-MpPreference|Remove-MpPreference)\b|\bDefender\b[^|\n]*\b(Disable|Exclusion)\b/i,
109
+ why: 'nothing this runner does requires changing the machine\'s antivirus settings, and doing so is what an attacker does first.',
110
+ },
111
+ {
112
+ name: 'persistence',
113
+ pattern: /\b(schtasks\s+[^|\n]*\/create|New-ScheduledTask|Register-ScheduledTask|New-Service|sc\s+create)\b|\\CurrentVersion\\Run\b/i,
114
+ why: 'a build or test step has no reason to outlive the run. Anything that survives a reboot is persistence and is for a person to set up deliberately.',
115
+ },
116
+ {
117
+ name: 'handler-launch',
118
+ /*
119
+ * Opening a file with whatever program Windows has registered for it — `Invoke-Item`, its alias
120
+ * `ii`, or `Start-Process` given a document rather than a program — and opening a URL, which
121
+ * starts the operator's browser. A build or a test starts the tool it needs by name; it never
122
+ * asks the shell to "open" something. Asking is how a `.hta`, a `.js` or a `.lnk` ends up run by
123
+ * `mshta`, the script host or whatever a shortcut points at, under this runner as the parent,
124
+ * and how a page ends up in the signed-in browser with the bot's process behind it.
125
+ */
126
+ pattern: /\b(Invoke-Item|ii)\s+\S|\b(Start-Process|saps|start)\s+(?:-\w+\s+)*(?:-FilePath\s+)?["']?(?:[^\s"']+\.(hta|vbs|vbe|js|jse|wsf|wsh|lnk|url|scr|cpl|msc|reg|inf|msi|msp|chm|pif)\b|https?:\/\/|ms-\w+:|file:\/\/)/i,
127
+ why: 'a file opened through its registered handler, or a URL opened in the browser, runs whatever Windows has registered for it under this runner. Start the program itself, by name, with the file as its argument.',
128
+ },
129
+ {
130
+ name: 'obfuscated-name',
131
+ /*
132
+ * Not a technique but the shape of hiding one, and worth more than chasing names.
133
+ *
134
+ * Attacking the list above found four ways past it that never touched a banned word:
135
+ * `cert^util` (cmd eats the caret), `cert""util` (the shell eats the empty quotes),
136
+ * `$a='cert'+'util'; & $a`, and `& (Get-Command cert*til)`. Every one runs
137
+ * `certutil` and none of them spells it. Chasing spellings is a race the writer wins, so
138
+ * this matches the hiding instead: a caret or an empty quote pair inside a word, a command
139
+ * name assembled from pieces, a name resolved by wildcard. None of the four has an honest
140
+ * use in a step that builds or tests a project — an honest step writes the name out.
141
+ */
142
+ pattern: /\w\^\w|\w(\"\"|'')\w|&\s*\(?\s*\$\w|\bGet-Command\s+[^\s|]*\*|\$\w+\s*=\s*['"][^'"]*['"]\s*\+/i,
143
+ why: 'the command name is hidden rather than written — a caret or empty quotes inside a word, a name built from pieces, or one resolved by wildcard. Write the command out in full; if it is one of the refused tools, it stays refused however it is spelled.',
144
+ },
145
+ ];
146
+ /**
147
+ * Why this text must not be run, or null.
148
+ *
149
+ * It was written to answer for a command line and for the body of a downloaded script alike, since
150
+ * `pwsh -File payload.ps1` and the `certutil` line inside `payload.ps1` produce the same process
151
+ * tree. There are no downloaded scripts any more; a script a task runs is written by a command
152
+ * step whose text passes through here first.
153
+ */
154
+ export function dangerousRefusal(text) {
155
+ for (const technique of DANGEROUS_TECHNIQUES) {
156
+ if (technique.pattern.test(text)) {
157
+ return `refused: ${technique.name}. ${technique.why}`;
158
+ }
159
+ }
160
+ return null;
161
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The tools a run actually ran with, written down once per task.
3
+ *
4
+ * Between two runs of the same plan, a day apart, `npm install` fetched TypeScript 6 and then
5
+ * 7, Next 15 and then 16, and the two runs behaved differently for reasons that took an
6
+ * afternoon to explain. "Latest" is a moving target and a plan that pins nothing gets a new
7
+ * world every time; the least the runner can do is say which world a run got. The project's
8
+ * own dependencies are in its lockfile, which the runner commits; this is the layer under
9
+ * that — the machine's Node, npm, git, both PowerShells, Edge, and the OS.
10
+ *
11
+ * Collected once per process and reused: versions do not change mid-run, and each probe is a
12
+ * child process.
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { release, type as osType } from 'node:os';
17
+ import { availableShells, detectShells } from './shells.js';
18
+ let cached = null;
19
+ /** One probe: its trimmed stdout, or null when the tool is missing or says nothing. */
20
+ function probe(file, args, shell = false) {
21
+ try {
22
+ // With a shell the command is one string; Node warns about args it would only concatenate.
23
+ const r = shell
24
+ ? spawnSync([file, ...args].join(' '), { encoding: 'utf8', windowsHide: true, timeout: 15_000, shell: true })
25
+ : spawnSync(file, args, { encoding: 'utf8', windowsHide: true, timeout: 15_000 });
26
+ return r.status === 0 && typeof r.stdout === 'string' && r.stdout.trim() ? r.stdout.trim().split('\n')[0].trim() : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ const EDGE_PATHS = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'];
33
+ export function collectEnvironment(fresh = false) {
34
+ if (cached && !fresh)
35
+ return cached;
36
+ const edgePath = EDGE_PATHS.find((p) => existsSync(p)) ?? null;
37
+ const psVersion = '$PSVersionTable.PSVersion.ToString()';
38
+ cached = {
39
+ collectedAt: new Date().toISOString(),
40
+ os: `${osType()} ${release()}`,
41
+ node: process.versions.node,
42
+ // npm is a .cmd on Windows, which spawn cannot start without a shell.
43
+ npm: probe('npm', ['-v'], true),
44
+ git: probe('git', ['--version'])?.replace(/^git version\s*/i, '') ?? null,
45
+ pwsh: probe('pwsh', ['-NoProfile', '-NonInteractive', '-Command', psVersion]),
46
+ powershell: probe('powershell', ['-NoProfile', '-NonInteractive', '-Command', psVersion]),
47
+ shells: detectShells({ fresh }),
48
+ edge: edgePath
49
+ ? { path: edgePath, version: probe('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Item '${edgePath}').VersionInfo.ProductVersion`]) }
50
+ : null,
51
+ };
52
+ return cached;
53
+ }
54
+ /** The manifest as a block of `name : version` lines, for the task log. */
55
+ export function describeEnvironment(e, defaultShell) {
56
+ const at = (shell) => (e.shells.found[shell] ? ` at ${e.shells.found[shell]}` : '');
57
+ /*
58
+ * What a command that named no shell was actually handed to.
59
+ *
60
+ * `execution.defaultShell` decides it whenever the machine has that shell, and only when it has
61
+ * not does the order take over. Reporting the order's answer regardless was wrong in the one
62
+ * case somebody would be reading this line to understand: a default of `cmd` on a machine that
63
+ * also has PowerShell 7 would be written down here as `pwsh`, which is the opposite of what ran.
64
+ */
65
+ const fallback = (defaultShell && e.shells.found[defaultShell] ? defaultShell : availableShells(e.shells)[0]) ?? null;
66
+ return [
67
+ `os : ${e.os}`,
68
+ `node : ${e.node}`,
69
+ `npm : ${e.npm ?? '(not found)'}`,
70
+ `git : ${e.git ?? '(not found)'}`,
71
+ `pwsh : ${e.pwsh ?? '(not found)'}${at('pwsh')}`,
72
+ `powershell : ${e.powershell ?? '(not found)'}${at('powershell')}`,
73
+ `cmd : ${e.shells.found.cmd ?? '(not found)'}`,
74
+ // The one line somebody reads when a step ran and behaved unlike the machine it was written
75
+ // on: which interpreter a command that named no shell was handed to.
76
+ `shell used : ${fallback ?? '(none — nothing can be run on this machine)'} when a step or a check names none`,
77
+ `edge : ${e.edge ? `${e.edge.version ?? '(version unknown)'} at ${e.edge.path}` : '(not found)'}`,
78
+ `collected : ${e.collectedAt}`,
79
+ ].join('\n');
80
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Where this runner is running, and whether that is anywhere safe.
3
+ *
4
+ * Every other gate in this round is a rule about what a command may say. This one is about the
5
+ * only thing that actually contains a command once it runs: the account it runs as. A deny list
6
+ * refuses a phrasing; a separate low-privilege Windows account refuses a capability, and no
7
+ * phrasing gets round it. The README has recommended one since before any of this, and the
8
+ * recommendation has had no mechanism behind it at all — a machine that ignored it looked exactly
9
+ * like a machine that had followed it, in the log and in the UI alike.
10
+ *
11
+ * What software can honestly do here is narrow, and pretending otherwise would be the worst
12
+ * outcome, because somebody would rely on it. A process cannot certify its own isolation: it
13
+ * cannot see the boundary it is inside, only the account it holds, and an attacker inside that
14
+ * account sees the same thing it does. So this module does three separate things and keeps them
15
+ * separate:
16
+ *
17
+ * the claim `execution.isolation` — what the operator says they have arranged. An
18
+ * assertion, recorded as an assertion, never as a finding.
19
+ * the signals the few facts that can be read cheaply and without being lied to by locale:
20
+ * the account name, whether the process holds High or System integrity, and
21
+ * whether the account is Windows Sandbox's own.
22
+ * the assessment where the two disagree, or where the combination is one nobody should be in.
23
+ *
24
+ * The one rule with teeth: an unattended run on a machine claiming no isolation is refused. That
25
+ * combination — nobody watching, nothing containing — is the shape that put a security team on the
26
+ * phone, and it is the one case where refusing costs a little and allowing costs everything.
27
+ */
28
+ import { spawnSync } from 'node:child_process';
29
+ import { winPsEnv } from './winps.js';
30
+ /** Windows Sandbox signs every process in it into this account. */
31
+ const SANDBOX_USER = 'wdagutilityaccount';
32
+ /*
33
+ * Integrity level is carried as a well-known SID, and a SID is the same on every Windows in every
34
+ * language. The text beside it is not: "High Mandatory Level" is translated, and a check written
35
+ * against the English would answer "not elevated" on a Bulgarian machine — the wrong answer in the
36
+ * unsafe direction, which is the one kind of wrong this must not be.
37
+ */
38
+ const INTEGRITY_HIGH = 'S-1-16-12288';
39
+ const INTEGRITY_SYSTEM = 'S-1-16-16384';
40
+ const INTEGRITY_MEDIUM = 'S-1-16-8192';
41
+ const INTEGRITY_LOW = 'S-1-16-4096';
42
+ let cached = null;
43
+ /**
44
+ * Whether the process holds an elevated (administrator) token, told in the integrity-level SID the
45
+ * rest of this file reads, or null when it cannot be asked. Separated so a test can supply its own.
46
+ *
47
+ * It used to run `whoami /groups`. `whoami.exe` started by another program is on nearly every
48
+ * endpoint product's list of account-discovery indicators, and this runs on company laptops, so it
49
+ * asks .NET instead, inside the one Windows PowerShell call: a principal is in the Administrators
50
+ * role only when its token is elevated — a UAC-filtered token answers no. The answer is `True` or
51
+ * `False` whatever the language of Windows, and is mapped onto the high and medium integrity SIDs
52
+ * so that `elevationFrom` stays the single reading of it.
53
+ */
54
+ function elevationProbe() {
55
+ try {
56
+ const r = spawnSync('powershell', [
57
+ '-NoProfile',
58
+ '-NonInteractive',
59
+ '-Command',
60
+ '([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)',
61
+ ], { encoding: 'utf8', windowsHide: true, timeout: 15_000, env: winPsEnv() });
62
+ if (r.status !== 0 || typeof r.stdout !== 'string')
63
+ return null;
64
+ const answer = r.stdout.trim();
65
+ if (answer === 'True')
66
+ return INTEGRITY_HIGH;
67
+ if (answer === 'False')
68
+ return INTEGRITY_MEDIUM;
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** Reads the integrity level out of text by SID. Exported for the check. */
76
+ export function elevationFrom(groups) {
77
+ if (!groups)
78
+ return null;
79
+ if (groups.includes(INTEGRITY_SYSTEM) || groups.includes(INTEGRITY_HIGH))
80
+ return true;
81
+ if (groups.includes(INTEGRITY_MEDIUM) || groups.includes(INTEGRITY_LOW))
82
+ return false;
83
+ return null;
84
+ }
85
+ export function readIsolationSignals(env = process.env, groups = elevationProbe, fresh = false) {
86
+ if (cached && !fresh)
87
+ return cached;
88
+ const user = env.USERNAME ?? '(unknown)';
89
+ cached = {
90
+ user,
91
+ computer: env.COMPUTERNAME ?? '(unknown)',
92
+ elevated: elevationFrom(groups()),
93
+ windowsSandbox: user.toLowerCase() === SANDBOX_USER,
94
+ };
95
+ return cached;
96
+ }
97
+ /** For tests, which supply their own environment and must not read a cached answer. */
98
+ export function forgetIsolationSignals() {
99
+ cached = null;
100
+ }
101
+ /**
102
+ * The posture: the claim, the signals, and every way the two of them are worth remarking on.
103
+ *
104
+ * A contradiction is reported rather than resolved. If the operator says "sandbox" and the account
105
+ * is not the sandbox's, this does not decide which is true — it says they disagree, because one of
106
+ * them is wrong and only a person can say which.
107
+ */
108
+ export function assessIsolation(claim, signals) {
109
+ const warnings = [];
110
+ if (claim === 'none' || claim === 'none-accepted') {
111
+ warnings.push('this run is not isolated: commands written by the model run as the ordinary user, with that ' +
112
+ "user's files, tokens and network access. Set execution.isolation once you have arranged a " +
113
+ 'separate low-privilege account, Windows Sandbox or a VM.');
114
+ }
115
+ if (claim === 'none-accepted') {
116
+ warnings.push('the operator has accepted unattended runs without isolation: a run with nobody watching may go ahead ' +
117
+ 'here, and nothing but the allowlist and the refusal rules limits what its steps reach.');
118
+ }
119
+ if (signals.elevated === true) {
120
+ warnings.push('this process is running elevated (High or System integrity), so every command it runs is ' +
121
+ 'elevated too. Nothing this runner does needs administrator rights; start it as an ordinary user.');
122
+ }
123
+ if (signals.elevated === null) {
124
+ warnings.push('whether this process is elevated could not be determined, so treat it as possibly elevated.');
125
+ }
126
+ if (claim === 'sandbox' && !signals.windowsSandbox) {
127
+ warnings.push(`execution.isolation says "sandbox", but this process is running as ${signals.user}, which is not ` +
128
+ "Windows Sandbox's own account. One of the two is wrong.");
129
+ }
130
+ if (claim === 'separate-account' && signals.windowsSandbox) {
131
+ warnings.push('execution.isolation says "separate-account", but this is Windows Sandbox. Say "sandbox" instead.');
132
+ }
133
+ return { claim, signals, warnings };
134
+ }
135
+ /**
136
+ * Why an unattended run may not start here, or null.
137
+ *
138
+ * Nobody watching and nothing containing is the one combination this refuses outright. Either
139
+ * side of it alone is a decision somebody can defend; together they are the arrangement that had
140
+ * a security team asking whether an automation tool had been compromised, and the honest answer
141
+ * at the time was that nothing would have looked any different if it had.
142
+ */
143
+ export function unattendedIsolationRefusal(mode, claim) {
144
+ if (mode !== 'unattended')
145
+ return null;
146
+ if (claim !== 'none')
147
+ return null;
148
+ return ('refused: an unattended run needs somewhere to run. With execution.isolation set to "none" there is ' +
149
+ 'nobody watching the steps and nothing limiting what they reach, which is the one combination this ' +
150
+ 'runner will not start. Run this task in confirm mode, or set execution.isolation once the bot has a ' +
151
+ 'separate low-privilege account, Windows Sandbox or a VM of its own — or, if you accept the risk, to ' +
152
+ '"none-accepted" ("This account — I accept unattended runs without isolation" in Settings).');
153
+ }
154
+ /** The posture as lines for the task log and `cop doctor`. */
155
+ export function describeIsolation(p) {
156
+ return [
157
+ `claimed : ${p.claim}${p.claim === 'none'
158
+ ? ' (the operator has not arranged any)'
159
+ : p.claim === 'none-accepted'
160
+ ? ' (none arranged; the operator accepts unattended runs anyway)'
161
+ : ''}`,
162
+ `account : ${p.signals.user} on ${p.signals.computer}${p.signals.windowsSandbox ? ' (Windows Sandbox)' : ''}`,
163
+ `elevated : ${p.signals.elevated === null ? 'could not be determined' : p.signals.elevated ? 'YES — every command runs elevated' : 'no'}`,
164
+ ...(p.warnings.length ? ['concerns :', ...p.warnings.map((w) => ` - ${w}`)] : ['concerns : none']),
165
+ ].join('\n');
166
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Commands that fetch something from the network, and why they never run on their own.
3
+ *
4
+ * The operator's rule, stated on 2026-09-27: the bot does not download files. Not because a
5
+ * download is dangerous in itself, but because of what comes after it. A step that saves
6
+ * `https://somewhere/tool.zip` into the project is harmless on the line where it happens; the
7
+ * *next* step, written by the same chat a minute later, can unpack and run it, and by then the
8
+ * thing that runs arrived from outside the conversation, was read by nobody, and is exactly the
9
+ * shape a security team calls a loader. `dangerous.ts` already refuses the one-line form
10
+ * (`iwr … | iex`); this is about the two-step form, which no single line gives away.
11
+ *
12
+ * So a step that fetches is not refused and not run: it is **held for the operator**. It goes to
13
+ * the approval screen ("A step is waiting for your decision") in every mode, *including* an
14
+ * unattended run and a run switched to "run the rest without asking", because those are exactly
15
+ * the runs in which nobody would otherwise see it. Where there is no one to ask — the CLI's
16
+ * unattended mode has no screen — it is refused instead, with the reason. See `authorizer.ts`.
17
+ *
18
+ * What is on the list, and what is deliberately not:
19
+ *
20
+ * on it `Invoke-WebRequest`/`iwr`, `Invoke-RestMethod`/`irm`, `curl`, `wget`,
21
+ * `Start-BitsTransfer` (and the `BitsTransfer` module), .NET's `WebClient`,
22
+ * `HttpClient` and `WebRequest`, and the file-transfer programs `ftp`, `tftp`, `sftp`,
23
+ * `scp` and `aria2c`. `bitsadmin` and `certutil -urlcache` are here too in spirit, but
24
+ * `dangerous.ts` refuses them outright first, which is stronger.
25
+ * not `npm`, `pnpm`, `yarn`, `dotnet restore`, `pip` into a `.venv`, `mvn`, `gradle`,
26
+ * `cargo`, `go`, `git fetch`/`pull`. They fetch too, but they fetch *packages*, the
27
+ * project's declared dependencies, by the project's own mechanism, from registries the
28
+ * project already trusts, and refusing them would stop every build. The operator decided
29
+ * that line (2026-09-27); moving it is their call, not this file's.
30
+ *
31
+ * One exception, and why: a request whose every target is **this machine** — `localhost`,
32
+ * `127.0.0.1`, `[::1]` — is not a download. `curl http://localhost:3000/health` is how a task
33
+ * proves the server it started actually answers, and nothing comes back that the project did not
34
+ * itself produce. Holding every health check for approval would turn an unattended run into one
35
+ * that waits all night on its first test. The exception is narrow on purpose: every `http(s)://`
36
+ * address in the command must be loopback, there must be at least one, and nothing else in the
37
+ * command may look like a host — a bare `example.com` or `10.0.0.5` beside a localhost URL still
38
+ * holds the step, because `iwr` accepts an address without a scheme.
39
+ *
40
+ * The same text is screened whole, not command by command. That is deliberate and it is what
41
+ * closes the obvious way round: writing `Invoke-WebRequest …` into a `.ps1` with `Set-Content` and
42
+ * running the file in the next step. The `Set-Content` step carries the words, so it is the one
43
+ * held, and the operator sees the script before it exists.
44
+ *
45
+ * The honest limit, the same one every rule in this folder states: this is not a boundary. A
46
+ * program the project itself contains can open a socket — a Node script that calls `fetch()` is
47
+ * ordinary application code and nothing here can tell it from a download. What this does is keep
48
+ * the *ordinary* way of downloading, the one a chat reaches for first, in front of a person.
49
+ */
50
+ import { existsSync } from 'node:fs';
51
+ import { dirname, join, resolve } from 'node:path';
52
+ import { botRootDir } from './workDir.js';
53
+ const FETCHERS = [
54
+ { name: 'Invoke-WebRequest', pattern: /\b(Invoke-WebRequest|iwr)\b/i },
55
+ { name: 'Invoke-RestMethod', pattern: /\b(Invoke-RestMethod|irm)\b/i },
56
+ { name: 'curl', pattern: /\bcurl(\.exe)?\b/i },
57
+ { name: 'wget', pattern: /\bwget2?(\.exe)?\b/i },
58
+ { name: 'Start-BitsTransfer', pattern: /\b(Start-BitsTransfer|BitsTransfer)\b/i },
59
+ // .NET's own clients, however they are spelled: `New-Object Net.WebClient`,
60
+ // `[System.Net.Http.HttpClient]::new()`, `[Net.WebRequest]::Create(...)`.
61
+ { name: '.NET web client', pattern: /\b(WebClient|HttpClient|HttpWebRequest|WebRequest)\b/i },
62
+ // Requires something after the name, so that the word in prose or a path segment is not a hit.
63
+ { name: 'file transfer', pattern: /\b(s?ftp|tftp|scp|aria2c)(\.exe)?\s+\S/i },
64
+ /*
65
+ * Package managers fetching something that is not a package from the project's registry: a
66
+ * package named by URL or by a git address, a runner (`npx`, `pnpm dlx`, `bunx`) given a package
67
+ * that is not installed in the project, a Go module run by address, a container image. Each
68
+ * downloads code and runs it in the same breath, which is the two-step form in one step.
69
+ * `npx <installed tool>` is the ordinary way to run a project's own tools and is not held: whether
70
+ * the tool is installed is checked on disk (`installedInProject`).
71
+ */
72
+ {
73
+ name: 'a package from a URL or a repository',
74
+ pattern: /\b(npm|pnpm|yarn|bun|pip3?|uv|poetry)(\.cmd|\.exe)?\s+(install|add|i)\b[^|;\n]*?(https?:\/\/|git\+|github:|gitlab:|bitbucket:|git:\/\/|ssh:\/\/|git@)/i,
75
+ instead: "name the package as the registry knows it, or say in `notes` what is needed and end `blocked`",
76
+ },
77
+ {
78
+ name: 'a package runner fetching a package',
79
+ // Any argument at all, flags included: whether the tool is installed decides, not the spelling.
80
+ pattern: /\b(npx|pnpx|bunx)(\.cmd|\.exe)?\s+\S|\b(pnpm|yarn)(\.cmd)?\s+dlx\s+|\bnpm(\.cmd)?\s+exec\s+/i,
81
+ instead: 'install the tool into the project first (`npm install -D <tool>`); a tool that is installed is run without asking',
82
+ },
83
+ { name: 'go run of a module by address', pattern: /\bgo(\.exe)?\s+run\s+\S+@/i, instead: 'add the module to go.mod and run it from there' },
84
+ {
85
+ name: 'a container image',
86
+ pattern: /\bdocker(\.exe)?\s+(pull|run|create|build)\b|\bdocker(\.exe)?\s+compose\s+(up|run|build|pull)\b/i,
87
+ instead: 'say in `notes` which image is needed; the operator pulls it',
88
+ },
89
+ ];
90
+ /**
91
+ * Whether the tool `npx` (or its kin) is asked to run is already installed in the project, found by
92
+ * walking up from `cwd` the way npx itself does. Absent, npx downloads it — and that is the case
93
+ * that waits for the operator.
94
+ */
95
+ function installedInProject(command, cwd) {
96
+ if (!cwd)
97
+ return false;
98
+ const m = /\b(?:npx|pnpx|bunx)(?:\.cmd|\.exe)?\s+(?:(?:--yes|-y|--no|--quiet|-q)\s+)*(?:(?:-p|--package)\s+\S+\s+)*(?:"([^"]+)"|'([^']+)'|(\S+))/i.exec(command)
99
+ ?? /\b(?:pnpm|yarn)(?:\.cmd)?\s+dlx\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i.exec(command)
100
+ ?? /\bnpm(?:\.cmd)?\s+exec\s+(?:--\s+)?(?:"([^"]+)"|'([^']+)'|(\S+))/i.exec(command);
101
+ if (!m)
102
+ return false;
103
+ if (/\s(-p|--package)\s/i.test(command))
104
+ return false;
105
+ const spec = (m[1] ?? m[2] ?? m[3] ?? '').trim();
106
+ if (!spec || spec.startsWith('-'))
107
+ return false;
108
+ // `tsx@4.19` names a version; `@scope/name` starts with `@`. Strip a trailing version only.
109
+ const name = spec.startsWith('@') ? spec.replace(/(.)@[^@]*$/, '$1') : spec.replace(/@.*$/, '');
110
+ if (/[\\/:]/.test(name.replace(/^@[^/]+\//, '')))
111
+ return false;
112
+ const bin = name.startsWith('@') ? name.split('/')[1] ?? '' : name;
113
+ let dir = resolve(cwd);
114
+ for (let i = 0; i < 12; i += 1) {
115
+ const modules = join(dir, 'node_modules');
116
+ if (existsSync(join(modules, name, 'package.json')) || existsSync(join(modules, '.bin', `${bin}.cmd`)) || existsSync(join(modules, '.bin', bin)))
117
+ return true;
118
+ const parent = dirname(dir);
119
+ if (parent === dir)
120
+ break;
121
+ dir = parent;
122
+ }
123
+ return false;
124
+ }
125
+ /** Hosts that are this machine. Anything else is somewhere else. */
126
+ function isLoopback(host) {
127
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '');
128
+ return h === 'localhost' || h.endsWith('.localhost') || /^127(\.\d{1,3}){3}$/.test(h) || h === '::1';
129
+ }
130
+ /**
131
+ * Extensions a bare `name.ext` token carries when it is a file rather than a host. Only used to
132
+ * decide whether a token beside a loopback URL looks like a *second* address; a file named here is
133
+ * an argument (`-OutFile health.json`), a token not named here is treated as a host, which is the
134
+ * safe direction to be wrong in: the step is held, not run.
135
+ */
136
+ const FILE_EXTENSIONS = new Set([
137
+ 'json', 'txt', 'log', 'md', 'html', 'htm', 'xml', 'csv', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf',
138
+ 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'map', 'ps1', 'psm1', 'cs', 'py', 'java', 'go', 'rs',
139
+ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'pdf', 'out', 'tmp', 'bak', 'lock',
140
+ 'exe', 'cmd', 'bat', 'dll', 'tar', 'gz', 'tgz',
141
+ ]);
142
+ /**
143
+ * Whether every place this command could reach is this machine. True only when it names at least
144
+ * one `http(s)://` address, all of them loopback, and no other token that reads as a host or an IP.
145
+ */
146
+ export function onlyLoopbackTargets(command) {
147
+ const urls = [...command.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/(\[[^\]]+\]|[^\/\s:'"`;|)]+)/gi)];
148
+ if (urls.length === 0)
149
+ return false;
150
+ if (!urls.every((m) => isLoopback(m[1])))
151
+ return false;
152
+ const rest = command.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ');
153
+ for (const token of rest.split(/[\s'"`=,;|(){}]+/)) {
154
+ const bare = token.replace(/^-+/, '').replace(/[:\/].*$/, '');
155
+ if (!bare)
156
+ continue;
157
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(bare) && !isLoopback(bare))
158
+ return false;
159
+ const labels = bare.split('.');
160
+ if (labels.length < 2 || labels.some((l) => l === ''))
161
+ continue;
162
+ const last = labels[labels.length - 1].toLowerCase();
163
+ if (!/^[a-z]{2,}$/.test(last))
164
+ continue;
165
+ if (FILE_EXTENSIONS.has(last))
166
+ continue;
167
+ // PowerShell member access on a variable or a type (`$r.StatusCode`, `System.Net`) is not a host.
168
+ if (token.startsWith('$') || token.startsWith('['))
169
+ continue;
170
+ if (/^(system|microsoft|net)$/i.test(labels[0]))
171
+ continue;
172
+ if (isLoopback(bare))
173
+ continue;
174
+ return false;
175
+ }
176
+ return true;
177
+ }
178
+ /**
179
+ * Why this command must be shown to the operator before it runs, or null.
180
+ *
181
+ * The sentence is the one the approval screen and the log print, and — when there is nobody to
182
+ * ask — the one the chat receives, so it says what was recognised and what to do instead.
183
+ */
184
+ export function networkFetchReason(command, cwd) {
185
+ const hit = FETCHERS.find((f) => f.pattern.test(command));
186
+ if (!hit)
187
+ return null;
188
+ if (hit.name === 'a package runner fetching a package' && installedInProject(command, cwd))
189
+ return null;
190
+ if (onlyLoopbackTargets(command))
191
+ return null;
192
+ return (`fetches from the network (${hit.name}). The bot does not download files on its own, because a later ` +
193
+ 'step could run what arrived; this step waits for the operator to allow it. Requests to this machine ' +
194
+ '(localhost, 127.0.0.1) are not held. Packages come in through the project\'s own tools (npm, dotnet, ' +
195
+ 'pip into a .venv), which are not held either.' +
196
+ (hit.instead ? ` Instead: ${hit.instead}.` : ''));
197
+ }
198
+ /**
199
+ * The bot's own API and UI, and the file that holds their key. The loopback exemption above exists
200
+ * so a task can check a server *it* started; it must not become the way a step reaches the process
201
+ * that approves its steps. Found on 2026-09-27: with the token in its environment, a step could call
202
+ * `127.0.0.1:4000` and answer its own held download. The token is no longer passed (`stepEnv.ts`);
203
+ * this refuses the attempt as well, so a step that goes looking gets a reason instead of a 401.
204
+ */
205
+ export function botSelfRefusal(command, ports = botPorts(), root = botRootDir()) {
206
+ const port = `(?:${ports.map((p) => String(Math.trunc(p))).join('|')})`;
207
+ const host = String.raw `(?:localhost|127(?:\.\d{1,3}){3}|\[::1\]|0\.0\.0\.0|::1)`;
208
+ // The folders the bot keeps its records in, by path: its data, its runs, its update backups.
209
+ const folded = command.replace(/\//g, '\\').toLowerCase();
210
+ const own = root.replace(/\//g, '\\').toLowerCase().replace(/\\+$/, '');
211
+ const reachesRecords = ['data', 'runs', 'data-backups'].some((d) => folded.includes(`${own}\\${d}`));
212
+ /*
213
+ * Installed from npm, the bot is a dependency of the very project it works on: its code sits in
214
+ * the project's `node_modules`, its records in the project's `.copilot-operator`, and both are
215
+ * inside the folders a step may write. A step that edited the bot's code or contract, or swapped
216
+ * its version, would be changing what runs the next task. So the bot's folders are refused by any
217
+ * spelling of the path, and so is installing, updating or removing the bot as a package.
218
+ */
219
+ const reachesPackage = /node_modules[\\/]+(?:@[^\\/\s]+[\\/]+)?copilot-operator(?![\w-])/i.test(command) ||
220
+ /(?:^|[\s'"`=(\\/,;])\.copilot-operator(?:[\\/\s'"`;|)]|$)/i.test(command) ||
221
+ /\b(?:npm|pnpm|yarn|bun)(?:\.cmd|\.exe)?\b[^|;\n]*\b(?:i|install|add|ci|un|uninstall|remove|rm|r|update|up|upgrade|link|ln)\b[^|;\n]*(?:^|\s|@[^\s/]+\/)copilot-operator(?![\w-])/i.test(command);
222
+ if (reachesPackage) {
223
+ return ("refused: this reaches the bot's own package — its code in node_modules, its records in .copilot-operator, or " +
224
+ 'its installation. The bot that runs the task is not part of the work; leave both folders and the dependency alone.');
225
+ }
226
+ if (reachesRecords || new RegExp(String.raw `${host}\s*:\s*${port}(?!\d)`, 'i').test(command) || /\bapi-token\b|\bdev-pids\.json\b/i.test(command)) {
227
+ return ('refused: this reaches the bot itself — its API, its web page or its key. A task works on its own project; ' +
228
+ 'the bot that runs it is not part of the work. Use a different port for a server the task starts.');
229
+ }
230
+ return null;
231
+ }
232
+ /** The ports the bot's API and web page listen on. */
233
+ export function botPorts(env = process.env) {
234
+ const api = Number(env.COP_API_PORT ?? 4000);
235
+ return [...new Set([Number.isFinite(api) ? api : 4000, 4000, 3210])];
236
+ }
237
+ /** What the chat is told when a fetch was held and nobody was there to allow it. */
238
+ export function networkFetchRefusal(reason) {
239
+ return (`refused: ${reason} This run has nobody to approve it, so it was not run. Do the work without ` +
240
+ 'downloading — write what is needed into the project with a command step, or install it with the ' +
241
+ "project's package manager — or say in `notes` what must be fetched and end `blocked`.");
242
+ }