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,201 @@
1
+ import { effectiveShell } from './shells.js';
2
+ import { dangerousRefusal } from './dangerous.js';
3
+ import { findShellExecuteTrap } from './shellExecuteTrap.js';
4
+ import { inlineCodeRefusal, programRefusal } from './programs.js';
5
+ import { unattendedIsolationRefusal } from './isolation.js';
6
+ import { confinementRefusal } from './confinement.js';
7
+ import { botSelfRefusal, networkFetchReason, networkFetchRefusal } from './network.js';
8
+ import { scriptFileRefusal } from './scriptFiles.js';
9
+ export function describeStep(step) {
10
+ // The shell it will really be read by, not the one this file used to assume: a line in the
11
+ // log that names an interpreter the machine has not got explains the wrong failure.
12
+ return `[${effectiveShell(step.shell)}] ${step.cmd}`;
13
+ }
14
+ /** Returns the first deny pattern that matches, or null. */
15
+ export function matchDenyPattern(text, patterns) {
16
+ for (const p of patterns) {
17
+ try {
18
+ if (new RegExp(p, 'i').test(text))
19
+ return p;
20
+ }
21
+ catch {
22
+ // An invalid pattern in the config must not silently disable the whole deny list.
23
+ if (text.toLowerCase().includes(p.toLowerCase()))
24
+ return p;
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * A step that reaches into the repository's own machinery: a path inside `.git`, or a git setting
31
+ * written to stay. Built in, like `dangerous.ts`, because the operator's deny list is editable and
32
+ * this is not a matter of taste.
33
+ *
34
+ * Found on 2026-09-27: the runner's own git — the branch before a task, `add` and `commit` after
35
+ * it, `status` for the commit-clean check — runs whatever hooks, filters and fsmonitor the
36
+ * repository's config names. A step could write `.git\hooks\post-checkout`, or `git config
37
+ * core.fsmonitor <program>`, and the *runner* would then execute it: no approval, no allowlist, no
38
+ * step log. The runner now starts git with those switched off (`src/vcs/git.ts`); this stops the
39
+ * step from setting them up in the first place. Reading history through git is unaffected — the
40
+ * contract already tells the chat to use git commands, never the folder.
41
+ */
42
+ export function repositoryInternalsRefusal(command) {
43
+ const intoGitDir = /(^|[\s'"=(\\/,;])\.git[\\/]/i.test(command);
44
+ const setting = /\bgit\s+(?:-C\s+\S+\s+)?config\s+(?:(?:--(?:local|global|system|worktree)|--file\s+\S+)\s+)?(?:(?:set|unset)\s+)?[A-Za-z][\w.-]*\s+[^\s|;-]/i.test(command);
45
+ // `git -c key=value <command>` sets the key for that one command — and some keys name a program
46
+ // git then starts (hooks, fsmonitor, the pager, the editor, credential helpers, filters, diff and
47
+ // merge drivers, aliases), which the allowlist never sees. Those keys are refused inline as well.
48
+ const inline = /\bgit\b[^|;\n]*?\s-c\s*["']?(?:core\.(?:hooksPath|fsmonitor|sshCommand|gitProxy|editor|pager|askPass|alternateRefsCommand|attributesFile)|credential\.|gpg\.|diff\.[^\s=]*\.(?:command|textconv)|difftool\.|mergetool\.|merge\.[^\s=]*\.driver|filter\.|alias\.|sequence\.editor|uploadpack\.|receive\.|http\.proxy|url\.)/i.test(command);
49
+ if (!intoGitDir && !setting && !inline)
50
+ return null;
51
+ return ("refused: this reaches into the repository's own machinery (the .git folder, or a git setting written to stay). " +
52
+ 'The runner owns the repository and runs git itself; hooks and settings placed there would run as the runner. ' +
53
+ 'Read history with git commands (git log, git show, git diff) and leave .git alone.');
54
+ }
55
+ /**
56
+ * Why a command line must not run, or null. The one gate for a step's command and for a
57
+ * check's command alike: the deny list, then the forms that are not dangerous but cannot work
58
+ * (see `shellExecuteTrap.ts`) — refused for the same reason a check that would hang is refused,
59
+ * because the alternative is an iteration spent on a symptom.
60
+ */
61
+ export function commandRefusal(command, shell, denyPatterns, allowedPrograms = [], env = process.env,
62
+ /**
63
+ * The project folders and where this command runs. Absent only where there is no project to
64
+ * speak of — a check run by hand, a test of some other rule. Every path that runs a command for a
65
+ * task passes it: the implementer's steps and checks, and the reviewer's steps and derived checks.
66
+ */
67
+ confinement) {
68
+ // The built-in refusals first, because the operator's list can add to them and must not be
69
+ // able to stand in for them: a config written before they existed, or edited since, still gets
70
+ // them. See `dangerous.ts` for what is on the list and why each one is.
71
+ const technique = dangerousRefusal(command);
72
+ if (technique)
73
+ return technique;
74
+ // Before the operator's list: no configuration makes it right for a step to drive the bot.
75
+ const self = botSelfRefusal(command);
76
+ if (self)
77
+ return self;
78
+ const internals = repositoryInternalsRefusal(command);
79
+ if (internals)
80
+ return internals;
81
+ const hit = matchDenyPattern(command, denyPatterns);
82
+ if (hit)
83
+ return `matches deny pattern /${hit}/`;
84
+ // Where it reaches, before which program it is: a binary named by a path outside the project is
85
+ // better refused for being outside than for not being on a list, and a cmdlet — which the
86
+ // allowlist never sees — is only caught here. See `confinement.ts`.
87
+ if (confinement) {
88
+ const reach = confinementRefusal(command, confinement);
89
+ if (reach)
90
+ return reach;
91
+ }
92
+ // The allowlist after the known-bad floor and the operator's deny list, so their precise
93
+ // messages win, and this catches the long tail neither of them was ever going to enumerate: a
94
+ // program that is simply not part of this project's toolchain. See `programs.ts`.
95
+ const offlist = programRefusal(command, allowedPrograms);
96
+ if (offlist)
97
+ return offlist;
98
+ const trap = findShellExecuteTrap(command, shell, env);
99
+ if (trap)
100
+ return trap;
101
+ /*
102
+ * The scripts this line runs, read from disk and put through the same gate as the line. Only
103
+ * where the command's folder is known: that is every path that runs a command for a task.
104
+ */
105
+ if (confinement) {
106
+ return scriptFileRefusal(command, confinement.cwd, (text) => dangerousRefusal(text) ??
107
+ botSelfRefusal(text) ??
108
+ repositoryInternalsRefusal(text) ??
109
+ (matchDenyPattern(text, denyPatterns) ? `matches deny pattern /${matchDenyPattern(text, denyPatterns)}/` : null) ??
110
+ confinementRefusal(text, confinement) ??
111
+ programRefusal(text, allowedPrograms));
112
+ }
113
+ return null;
114
+ }
115
+ /**
116
+ * The gate for a check's command — the task's own checks and the reviewer's — which runs with
117
+ * nobody asked. A download in one is therefore refused rather than held, and the scripts it runs
118
+ * are read like a step's. One function, so the two callers cannot drift apart again (they had: the
119
+ * reviewer's checks refused downloads, the plan's did not).
120
+ */
121
+ export function checkCommandRefusal(command, shell, cfg, confinement, env = process.env) {
122
+ const refusal = commandRefusal(command, shell, cfg.denyPatterns, cfg.allowedPrograms, env, confinement);
123
+ if (refusal)
124
+ return refusal;
125
+ const network = networkFetchReason(command, confinement.cwd) ?? scriptNetworkReason(command, confinement.cwd);
126
+ return network ? networkFetchRefusal(network) : null;
127
+ }
128
+ /** A fetch inside a script the line runs — held or refused exactly as one on the line would be. */
129
+ export function scriptNetworkReason(command, cwd) {
130
+ let found = null;
131
+ scriptFileRefusal(command, cwd, (text) => {
132
+ found = networkFetchReason(text, cwd);
133
+ return null;
134
+ });
135
+ return found;
136
+ }
137
+ /**
138
+ * Why an unattended run may not begin at all, or null.
139
+ *
140
+ * Both conditions are about the same thing: an unattended run has given up the person who made
141
+ * every other rule survivable, so the two things that stand in for them have to be there. Nowhere
142
+ * to run and nobody watching is refused outright; an empty allowlist and nobody watching means
143
+ * nothing at all limits a step.
144
+ *
145
+ * Asked in two places on purpose. At the entrance — `start`, `startBatch`, the CLI, and the button
146
+ * that turns a running session unattended — so the operator is told once, before anything begins,
147
+ * what to set and where. And again in `staticCheck`, because the entrance is a courtesy and the
148
+ * step gate is the rule: a path that reached execution another way still finds it there.
149
+ *
150
+ * It is deliberately *not* a warning. The first version of this refused each step as it arrived,
151
+ * which technically held the line and in practice turned the ordinary "Run sessions" button into a
152
+ * run where every step came back refused — a gate that breaks the working bot is a gate that gets
153
+ * switched off, and the fix was to make the condition reachable rather than to soften it.
154
+ */
155
+ export function unattendedPrecondition(cfg) {
156
+ if (cfg.mode !== 'unattended')
157
+ return null;
158
+ if (cfg.lockedToConfirm) {
159
+ return 'refused: this machine\'s policy.lock.json forbids unattended runs. Run this task step by step.';
160
+ }
161
+ const unisolated = unattendedIsolationRefusal(cfg.mode, cfg.isolation ?? 'none');
162
+ if (unisolated)
163
+ return unisolated;
164
+ if (!cfg.allowedPrograms || cfg.allowedPrograms.length === 0) {
165
+ return ('refused: an unattended run requires execution.allowedPrograms to name the programs this project ' +
166
+ 'may start. With the allowlist empty and nobody watching, nothing limits what a step can run. ' +
167
+ 'Fill in the allowlist, or run this task step by step.');
168
+ }
169
+ return null;
170
+ }
171
+ /**
172
+ * Applies the automatic rules. Returns null when the step is acceptable so far, or a
173
+ * decision when it is refused without asking anyone.
174
+ */
175
+ export function staticCheck(step, cfg, env = process.env, confinement) {
176
+ // Screened against the shell that will actually read it: the traps in `shellExecuteTrap.ts`
177
+ // are shell-specific, so screening for one interpreter and running in another finds nothing.
178
+ const reason = commandRefusal(step.cmd, effectiveShell(step.shell), cfg.denyPatterns, cfg.allowedPrograms, env, confinement);
179
+ if (reason)
180
+ return { action: 'skip', reason };
181
+ /*
182
+ * Autonomy is graded: an unattended run carries strictly more restrictions than a watched one,
183
+ * because the thing that makes a watched run safe — a person reading each line — is exactly what
184
+ * an unattended run has removed.
185
+ *
186
+ * The precondition is the same one the entrances ask before anything starts; it is asked again
187
+ * here because the entrance is a courtesy and this is the path nothing goes round. The second
188
+ * rule is about an allowlisted interpreter used to evaluate a string, or a shell wrapped in a
189
+ * shell, which escapes the allowlist by construction: watched, a person can judge it; unwatched,
190
+ * it makes the list meaningless.
191
+ */
192
+ if (cfg.mode === 'unattended') {
193
+ const precondition = unattendedPrecondition(cfg);
194
+ if (precondition)
195
+ return { action: 'skip', reason: precondition };
196
+ const inline = inlineCodeRefusal(step.cmd);
197
+ if (inline)
198
+ return { action: 'skip', reason: inline };
199
+ }
200
+ return null;
201
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What was allowed, written down once per task, next to what was run.
3
+ *
4
+ * This exists because of a question nobody could answer quickly. A security team saw
5
+ * `certutil.exe` decoding a blob on an operator's workstation with this runner in the process
6
+ * tree, and asked the two reasonable things: what is this tool permitted to do, and was this
7
+ * deliberate. The run folder held every command and every exit code — excellent evidence of what
8
+ * happened — and nothing at all about what the rules had been at the time. So the answer had to be
9
+ * reconstructed from a config file that had been edited since, which is not an answer.
10
+ *
11
+ * A manifest fixes that by recording the policy beside the run it governed: the mode, the
12
+ * allowlist, whether downloaded files could execute, how many deny patterns and built-in refusals
13
+ * were in force, and who the process was running as. Lists are recorded by count and digest as
14
+ * well as in full, so "was this the same policy as last Tuesday" is one comparison rather than a
15
+ * reading exercise, and a quietly edited `dangerous.ts` shows up as a changed digest.
16
+ *
17
+ * What it deliberately does not claim: that the run was isolated. Whether this process sits in a
18
+ * sandbox or a separate low-privilege account is the operator's assertion to make, not something
19
+ * software can honestly detect from the inside, so the manifest records the account it sees and
20
+ * says plainly that isolation is not determined here. A manifest that overstated its own evidence
21
+ * would be worse than none, because somebody would rely on it.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { DANGEROUS_TECHNIQUES } from './dangerous.js';
25
+ import { describeIsolation } from './isolation.js';
26
+ /** A short, stable digest of a list, order-independent so a reordered config is not a changed one. */
27
+ export function digestOf(values) {
28
+ // JSON rather than a separator character: unambiguous for any entry, and free of the control
29
+ // byte an earlier version joined on, which made this file read as binary to grep and every
30
+ // other tool that decides by looking for one.
31
+ const canonical = JSON.stringify([...values].map((v) => v.trim()).filter(Boolean).sort());
32
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 12);
33
+ }
34
+ export const NETWORK_FETCH = "a command that fetches from the network (Invoke-WebRequest, Invoke-RestMethod, curl, wget, Start-BitsTransfer, .NET web clients, ftp/scp) is held for the operator in every mode; with nobody to ask it is refused. Requests to localhost and the project's package managers are not held";
35
+ export function collectPolicyManifest(input, env = process.env) {
36
+ const names = DANGEROUS_TECHNIQUES.map((t) => t.name);
37
+ return {
38
+ collectedAt: new Date().toISOString(),
39
+ mode: input.mode,
40
+ allowlist: {
41
+ // An empty list is the gate turned off, and that is the fact worth recording plainly: it is
42
+ // the difference between "only these programs" and "any program at all".
43
+ enforced: input.allowedPrograms.length > 0,
44
+ count: input.allowedPrograms.length,
45
+ digest: digestOf(input.allowedPrograms),
46
+ programs: [...input.allowedPrograms],
47
+ },
48
+ chatFiles: 'the chat cannot supply a file: this runner has no file step and executes nothing it did not receive as a command',
49
+ networkFetch: NETWORK_FETCH,
50
+ denyPatterns: { count: input.denyPatterns.length, digest: digestOf(input.denyPatterns) },
51
+ builtIn: { count: names.length, digest: digestOf(names), names },
52
+ lock: input.lock ?? { applied: false, changes: [] },
53
+ account: {
54
+ user: env.USERNAME ?? '(unknown)',
55
+ domain: env.USERDOMAIN ?? '(unknown)',
56
+ computer: env.COMPUTERNAME ?? '(unknown)',
57
+ },
58
+ isolation: input.isolation,
59
+ cwd: input.cwd,
60
+ confinedTo: [...(input.confinedTo ?? [])],
61
+ };
62
+ }
63
+ /** The manifest as a block of `name : value` lines, for the task log, beside the environment. */
64
+ export function describePolicyManifest(m) {
65
+ return [
66
+ `mode : ${m.mode}${m.mode === 'unattended' ? ' (no person saw the steps as they ran)' : ' (a person approved each step)'}`,
67
+ `allowlist : ${m.allowlist.enforced ? `${m.allowlist.count} programs, digest ${m.allowlist.digest}` : 'NOT ENFORCED — any program could be started'}`,
68
+ `chat files : ${m.chatFiles}`,
69
+ `downloads : ${m.networkFetch}`,
70
+ `deny list : ${m.denyPatterns.count} patterns, digest ${m.denyPatterns.digest}`,
71
+ `built-in : ${m.builtIn.count} refused techniques, digest ${m.builtIn.digest} (cannot be switched off)`,
72
+ `lock : ${m.lock.applied
73
+ ? m.lock.changes.length
74
+ ? `policy.lock.json in force — ${m.lock.changes.join('; ')}`
75
+ : 'policy.lock.json in force — nothing to tighten'
76
+ : 'none — this machine has no administrator policy file'}`,
77
+ `account : ${m.account.domain}\\${m.account.user} on ${m.account.computer}`,
78
+ 'isolation :',
79
+ describeIsolation(m.isolation).split('\n').map((l) => ` ${l}`).join('\n'),
80
+ `cwd : ${m.cwd}`,
81
+ `confined to : ${m.confinedTo.length ? m.confinedTo.join(', ') : 'NOTHING — commands were not held to a project'}`,
82
+ `collected : ${m.collectedAt}`,
83
+ ].join('\n');
84
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * What a task or a review leaves running, found and stopped by the runner — and how anything the
3
+ * runner stops is stopped.
4
+ *
5
+ * "Anything you start, you stop" is in both contracts, and it is advice: in one run the reviewer
6
+ * left its own `next start` listening on 4310 and then failed the work for it, and every plan
7
+ * written so far has carried its own checks for ports and node processes because nothing else
8
+ * would. The runner can own this. It notes when a task and each review began; afterwards, what was
9
+ * created since *and was started by the bot* is stopped and written on the task with where it came
10
+ * from. The plan's own checks still run first — a server the implementer forgot goes back to the
11
+ * chat as a failed check — and this is the net under them.
12
+ *
13
+ * **Whose processes** (2026-09-27, at the operator's request). The first version stopped every new
14
+ * process whose command line named the project folder. That is a description of a place, not of an
15
+ * owner: a server the operator started by hand in the same folder while a run was going matched it
16
+ * exactly, and was killed. Now a process is the bot's only if it descends from a step the bot itself
17
+ * started — every shell `runStep` spawns is recorded in a `ProcessTracker`, with when it started and
18
+ * ended, and a process belongs to the run if its chain of parents reaches one of them. Windows does
19
+ * not re-parent an orphan, so a server left behind by `Start-Process` still names the step's shell
20
+ * as its parent after that shell has exited; the times are what stop a *reused* process id from
21
+ * passing for it. What is new in the folder but not provably the bot's is **reported and left
22
+ * running**. The honest limit: a program started through a wrapper that exits at once (`cmd /c
23
+ * start …`) leaves a process whose parent is gone and was never recorded; it is reported, not
24
+ * stopped.
25
+ *
26
+ * **How it looks from the outside.** This runs on company laptops whose security tooling watches
27
+ * every process the bot starts, so what the bot itself does here is kept to the plainest forms:
28
+ *
29
+ * - The process table is read only for processes *created since* the task or step began (a WMI
30
+ * filter on `CreationDate`), not the whole machine with every command line, and the listening
31
+ * ports only for the processes found. Reading everyone's command lines several times a task is
32
+ * what discovery tooling does, and the bot has no need to know about anything older than its
33
+ * own work.
34
+ * - Stopping is `taskkill /T` first — the polite form, which asks a windowed program to close — a
35
+ * grace period, and `taskkill /T /F` only for what is still there. A day's version of this file
36
+ * sent a real Ctrl+C by compiling kernel32 calls through `Add-Type`; it worked, and it was
37
+ * removed the same day at the operator's decision, because runtime-compiled C# calling
38
+ * `AttachConsole` in another process's console is exactly what endpoint protection is built to
39
+ * flag. The cost is honest: a console server (node, dotnet) is still ended by `/F`. It is only
40
+ * ever one the bot started, which is what matters more.
41
+ * - The PowerShell that reads the table is given its script on the command line, where anyone
42
+ * reading the process list can see all of it — never on stdin, and never encoded.
43
+ */
44
+ import { spawn } from 'node:child_process';
45
+ import { winPsEnv } from './winps.js';
46
+ /** How long a process is given to close after the polite `taskkill`, before `/F`. */
47
+ export const CLOSE_GRACE_MS = 5_000;
48
+ /**
49
+ * The shells the bot has started, with when each started and ended.
50
+ *
51
+ * One per task, handed to every `runStep` the task makes — its steps, its checks, its reviews — so
52
+ * that "the bot's processes" means this task's, and a second session running at the same time in
53
+ * another project is somebody else. A list rather than a map because Windows reuses process ids,
54
+ * and two steps of one task can be given the same one.
55
+ */
56
+ export class ProcessTracker {
57
+ roots = [];
58
+ started(pid, at = Date.now()) {
59
+ this.roots.push({ pid, from: at });
60
+ }
61
+ ended(pid, at = Date.now()) {
62
+ for (let i = this.roots.length - 1; i >= 0; i -= 1) {
63
+ const r = this.roots[i];
64
+ if (r.pid === pid && r.to === undefined) {
65
+ r.to = at;
66
+ return;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ /** Runs one PowerShell expression, visible on the command line, and returns its stdout or null. */
72
+ function powershell(script, timeoutMs = 20_000) {
73
+ return new Promise((resolve) => {
74
+ const child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, env: winPsEnv() });
75
+ let out = '';
76
+ let settled = false;
77
+ const finish = (value) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ resolve(value);
82
+ };
83
+ const timer = setTimeout(() => {
84
+ try {
85
+ child.kill();
86
+ }
87
+ catch {
88
+ /* already gone */
89
+ }
90
+ finish(null);
91
+ }, timeoutMs);
92
+ child.stdout.on('data', (d) => (out += d.toString('utf8')));
93
+ child.on('error', () => {
94
+ clearTimeout(timer);
95
+ finish(null);
96
+ });
97
+ child.on('close', (code) => {
98
+ clearTimeout(timer);
99
+ finish(code === 0 ? out : null);
100
+ });
101
+ });
102
+ }
103
+ function parseJsonList(text) {
104
+ if (!text?.trim())
105
+ return [];
106
+ try {
107
+ const parsed = JSON.parse(text);
108
+ return Array.isArray(parsed) ? parsed : [parsed];
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ }
114
+ /** Windows FILETIME (100 ns since 1601) to milliseconds since 1970. */
115
+ const fromFileTime = (ft) => (ft > 0 ? Math.round(ft / 10_000 - 11_644_473_600_000) : 0);
116
+ /**
117
+ * A moment as WMI's own datetime literal, `yyyymmddHHMMSS.mmmmmm+000`, in UTC. Built here rather
118
+ * than by PowerShell's ManagementDateTimeConverter, which Constrained Language Mode does not allow
119
+ * and which corporate machines commonly enforce (verified 2026-09-27). Exported for the check.
120
+ */
121
+ export function dmtfOf(ms) {
122
+ const d = new Date(Math.max(0, ms));
123
+ const p = (n, w = 2) => String(n).padStart(w, '0');
124
+ return (`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
125
+ `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}000+000`);
126
+ }
127
+ /** Slack either side of a recorded start or end: the table's clock and ours are read separately. */
128
+ const SLACK_MS = 2_000;
129
+ /**
130
+ * The processes created at or after `since`, with their parents. Filtered in WMI itself, so rows
131
+ * older than the bot's work are never read at all.
132
+ */
133
+ export async function processTable(since) {
134
+ const from = dmtfOf(since - SLACK_MS);
135
+ const text = await powershell(`Get-CimInstance Win32_Process -Filter "CreationDate >= '${from}'" | Select-Object ProcessId, ParentProcessId, Name, CommandLine, ` +
136
+ "@{n='Created';e={ if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 } }} | ConvertTo-Json -Compress");
137
+ return parseJsonList(text)
138
+ .map((p) => ({
139
+ pid: p.ProcessId,
140
+ parent: p.ParentProcessId,
141
+ name: p.Name ?? '',
142
+ command: p.CommandLine ?? '',
143
+ created: fromFileTime(p.Created),
144
+ }))
145
+ .filter((p) => p.pid !== process.pid);
146
+ }
147
+ /** The ports these processes are listening on — asked about them only, not the whole machine. */
148
+ async function listenersOf(pids) {
149
+ if (pids.length === 0)
150
+ return [];
151
+ const text = await powershell(`Get-NetTCPConnection -State Listen -OwningProcess ${pids.map((p) => Math.trunc(p)).join(',')} -ErrorAction SilentlyContinue | ` +
152
+ 'Select-Object LocalPort, OwningProcess | ConvertTo-Json -Compress');
153
+ return parseJsonList(text).map((l) => ({ port: l.LocalPort, pid: l.OwningProcess }));
154
+ }
155
+ /**
156
+ * The rows that descend from one of `roots` — including a root itself while it is alive.
157
+ *
158
+ * A parent that is still alive is followed only when it is older than the child; if it is younger,
159
+ * its id was reused after the child's real parent died, and the recorded windows are consulted
160
+ * instead. A dead parent counts only when it is a recorded root and the child was created while
161
+ * that root was running — a process cannot be created by one that has already exited.
162
+ */
163
+ export function descendantsOf(table, roots) {
164
+ const byPid = new Map(table.map((r) => [r.pid, r]));
165
+ const memo = new Map();
166
+ const within = (pid, created) => roots.some((r) => r.pid === pid && created >= r.from - SLACK_MS && (r.to === undefined || created <= r.to + SLACK_MS));
167
+ const isRoot = (row) => roots.some((r) => r.pid === row.pid && Math.abs(row.created - r.from) <= SLACK_MS);
168
+ const ours = (row, depth) => {
169
+ const known = memo.get(row.pid);
170
+ if (known !== undefined)
171
+ return known;
172
+ if (depth > 64)
173
+ return false;
174
+ memo.set(row.pid, false); // a cycle in reused ids is not ownership
175
+ let result = isRoot(row);
176
+ if (!result && row.parent !== row.pid) {
177
+ const parent = byPid.get(row.parent);
178
+ // An older parent that is still alive *is* the parent, ours or not; only when it is gone, or
179
+ // its id now belongs to a younger process, do the recorded windows decide.
180
+ if (parent && parent.created <= row.created + SLACK_MS)
181
+ result = parent.pid !== 0 && ours(parent, depth + 1);
182
+ else
183
+ result = within(row.parent, row.created);
184
+ }
185
+ memo.set(row.pid, result);
186
+ return result;
187
+ };
188
+ return table.filter((row) => ours(row, 0));
189
+ }
190
+ export function isAlive(pid) {
191
+ try {
192
+ process.kill(pid, 0);
193
+ return true;
194
+ }
195
+ catch (e) {
196
+ // EPERM means it exists and belongs to someone else, which is still "there".
197
+ return e.code === 'EPERM';
198
+ }
199
+ }
200
+ async function waitGone(pids, ms) {
201
+ const until = Date.now() + ms;
202
+ while (Date.now() < until && pids.some(isAlive))
203
+ await new Promise((r) => setTimeout(r, 200));
204
+ }
205
+ function taskkill(pid, force) {
206
+ return new Promise((resolve) => {
207
+ try {
208
+ const args = ['/pid', String(pid), '/T', ...(force ? ['/F'] : [])];
209
+ const child = spawn('taskkill', args, { stdio: 'ignore', windowsHide: true });
210
+ child.on('close', () => resolve());
211
+ child.on('error', () => resolve());
212
+ }
213
+ catch {
214
+ resolve();
215
+ }
216
+ });
217
+ }
218
+ /**
219
+ * Stops exactly these processes: `taskkill /T`, a grace period, then `taskkill /T /F`.
220
+ *
221
+ * The list is the whole set, worked out by the caller before anything is stopped — not a root to be
222
+ * expanded with `/T` — because a tree whose root has already exited is invisible to `/T`, and that
223
+ * is precisely the shape of a server left behind by `Start-Process`. `/T` is still passed, for
224
+ * whatever a process managed to start while it was being stopped.
225
+ *
226
+ * This process and its parent are never on the list, whatever a caller passes.
227
+ */
228
+ export async function stopProcesses(pids, opts = {}) {
229
+ const how = new Map();
230
+ const protect = new Set([process.pid, process.ppid]);
231
+ const wanted = [...new Set(pids)].filter((p) => !protect.has(p));
232
+ for (const p of wanted)
233
+ if (!isAlive(p))
234
+ how.set(p, 'already-gone');
235
+ let live = wanted.filter(isAlive);
236
+ if (live.length > 0) {
237
+ for (const p of live)
238
+ await taskkill(p, false);
239
+ await waitGone(live, opts.graceMs ?? CLOSE_GRACE_MS);
240
+ for (const p of live)
241
+ if (!isAlive(p))
242
+ how.set(p, 'closed');
243
+ live = live.filter(isAlive);
244
+ }
245
+ for (const p of live)
246
+ await taskkill(p, true);
247
+ await waitGone(live, 2_000);
248
+ for (const p of live)
249
+ how.set(p, isAlive(p) ? 'failed' : 'forced');
250
+ return how;
251
+ }
252
+ /**
253
+ * Stops a process the runner started and everything under it that is still running — the form the
254
+ * runner uses for a step that timed out, went silent or was aborted. `since` is when it was started,
255
+ * so only processes from then on are read. When the table cannot be read, it falls back to
256
+ * `taskkill /T /F` on the root, which is what it always did.
257
+ */
258
+ export async function stopTree(rootPid, since, opts = {}) {
259
+ const table = await processTable(since);
260
+ const root = table.find((r) => r.pid === rootPid);
261
+ if (!root) {
262
+ await taskkill(rootPid, true);
263
+ return new Map([[rootPid, isAlive(rootPid) ? 'failed' : 'forced']]);
264
+ }
265
+ const tree = descendantsOf(table, [{ pid: rootPid, from: root.created }]).map((r) => r.pid);
266
+ return await stopProcesses(tree, opts);
267
+ }
268
+ /** Folded for comparison: Windows paths are case-insensitive and can use either slash. */
269
+ const fold = (s) => s.replace(/\//g, '\\').toLowerCase();
270
+ /**
271
+ * The moment watching begins. Taken at the start of a task it reads nothing — nothing the task
272
+ * started exists yet. Taken before a review it reads what the task's own work started since `from`,
273
+ * so the review is answerable only for what it adds.
274
+ */
275
+ export async function snapshotProcesses(_projectDir, from) {
276
+ const since = Date.now();
277
+ if (from === undefined)
278
+ return { since, processes: [], listeners: [] };
279
+ return { since: from, processes: await processTable(from), listeners: [] };
280
+ }
281
+ /**
282
+ * What was created since the snapshot: the part of it the bot started (`ours`), and the part that
283
+ * names the project folder but cannot be tied to a step (`notOurs`), each with its ports. Without a
284
+ * tracker nothing is the bot's, and everything new in the folder is `notOurs`.
285
+ */
286
+ export async function findLeftovers(projectDir, before, tracker) {
287
+ const now = await processTable(before.since);
288
+ // A process is "the same" only if its id *and* its creation time match: an id alone is reused.
289
+ const known = new Set(before.processes.map((p) => `${p.pid}@${p.created}`));
290
+ // The console host of a server's own window is its child in the table, and goes when the last
291
+ // process on that console does; listing it would only be noise beside the server it belongs to.
292
+ const fresh = now.filter((p) => !known.has(`${p.pid}@${p.created}`) && p.name.toLowerCase() !== 'conhost.exe');
293
+ const mine = new Set(tracker ? descendantsOf(now, tracker.roots).map((r) => r.pid) : []);
294
+ const folder = fold(projectDir);
295
+ const ours = fresh.filter((p) => mine.has(p.pid));
296
+ const notOurs = fresh
297
+ .filter((p) => !mine.has(p.pid) && folder !== '' && fold(p.command).includes(folder))
298
+ // The runner's own table query names nothing of the project, but keep it out regardless.
299
+ .filter((p) => !p.command.includes('Get-CimInstance Win32_Process'));
300
+ const listeners = await listenersOf([...ours, ...notOurs].map((p) => p.pid));
301
+ const withPorts = (p) => ({
302
+ pid: p.pid,
303
+ parent: p.parent,
304
+ name: p.name,
305
+ command: p.command,
306
+ ports: listeners.filter((l) => l.pid === p.pid).map((l) => l.port),
307
+ });
308
+ return { ours: ours.map(withPorts), notOurs: notOurs.map(withPorts) };
309
+ }
310
+ /** Stops what the bot left running since the snapshot, and says what it stopped and what it did not. */
311
+ export async function reapLeftovers(projectDir, before, tracker) {
312
+ const { ours, notOurs } = await findLeftovers(projectDir, before, tracker);
313
+ const result = { killed: [], failed: [], notOurs };
314
+ if (ours.length === 0)
315
+ return result;
316
+ const how = await stopProcesses(ours.map((l) => l.pid));
317
+ for (const l of ours) {
318
+ const h = how.get(l.pid) ?? 'failed';
319
+ (h === 'failed' ? result.failed : result.killed).push({ ...l, how: h });
320
+ }
321
+ return result;
322
+ }
323
+ /** One line per leftover, for the event and the record. */
324
+ export function describeLeftovers(leftovers) {
325
+ return leftovers
326
+ .map((l) => `${l.name} (pid ${l.pid}${l.ports.length > 0 ? `, listening on ${l.ports.join(', ')}` : ''}${l.how ? `, ${l.how}` : ''}): ` +
327
+ l.command.slice(0, 160))
328
+ .join('\n');
329
+ }