windows-development-sandbox 1.11.7 → 1.12.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 (121) hide show
  1. package/README.md +12 -6
  2. package/config.example.json +4 -3
  3. package/dist/app/features/apps/apps-rows.d.ts +4 -1
  4. package/dist/app/features/apps/apps-rows.js +7 -2
  5. package/dist/app/features/apps/apps.def.d.ts +19 -0
  6. package/dist/app/features/apps/apps.def.js +49 -12
  7. package/dist/app/features/apps/apps.run.d.ts +2 -0
  8. package/dist/app/features/apps/apps.run.js +1 -1
  9. package/dist/app/features/audit/audit.def.d.ts +341 -0
  10. package/dist/app/features/audit/audit.def.js +454 -0
  11. package/dist/app/features/audit/audit.run.d.ts +107 -0
  12. package/dist/app/features/audit/audit.run.js +133 -0
  13. package/dist/app/features/audit/index.d.ts +2 -0
  14. package/dist/app/features/audit/index.js +3 -0
  15. package/dist/app/features/defs.d.ts +194 -1
  16. package/dist/app/features/defs.js +4 -0
  17. package/dist/app/features/settings/settings.run.d.ts +3 -0
  18. package/dist/app/features/settings/settings.run.js +16 -2
  19. package/dist/app/kernel/define.d.ts +12 -1
  20. package/dist/app/registry.js +2 -0
  21. package/dist/cli/args.d.ts +0 -3
  22. package/dist/cli/args.js +3 -4
  23. package/dist/cli/commands/index.d.ts +1 -1
  24. package/dist/cli/commands/index.js +1 -1
  25. package/dist/cli/commands/launch/prepare.js +1 -1
  26. package/dist/cli/commands/maintenance/index.d.ts +0 -1
  27. package/dist/cli/commands/maintenance/index.js +0 -1
  28. package/dist/cli/commands/registry.js +1 -2
  29. package/dist/cli/manifest.d.ts +0 -41
  30. package/dist/cli/manifest.js +0 -14
  31. package/dist/cli/menu/pages/apps-page.js +2 -15
  32. package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
  33. package/dist/cli/menu/pages/maintenance-page.js +51 -8
  34. package/dist/cli/menu/views.js +2 -0
  35. package/dist/core/config/defaults.js +1 -1
  36. package/dist/core/schema/apps.d.ts +9 -0
  37. package/dist/core/schema/apps.js +9 -0
  38. package/dist/core/schema/config.d.ts +1 -0
  39. package/dist/core/schema/config.js +3 -3
  40. package/dist/core/schema/sandbox.d.ts +1 -0
  41. package/dist/core/schema/sandbox.js +11 -3
  42. package/dist/guest/apps/app-installer.js +50 -4
  43. package/dist/guest/apps/index.d.ts +1 -1
  44. package/dist/guest/apps/index.js +1 -1
  45. package/dist/guest/apps/sources.js +1 -1
  46. package/dist/guest/audit/parse.d.ts +32 -2
  47. package/dist/guest/audit/parse.js +43 -5
  48. package/dist/guest/audit/process-audit.d.ts +3 -1
  49. package/dist/guest/audit/process-audit.js +9 -6
  50. package/dist/guest/boot/boot.js +12 -3
  51. package/dist/guest/boot/guide.d.ts +1 -1
  52. package/dist/guest/boot/guide.js +27 -3
  53. package/dist/guest/daemon/sessions.d.ts +7 -0
  54. package/dist/guest/daemon/sessions.js +27 -2
  55. package/dist/guest/entries/daemon.js +2 -1
  56. package/dist/guest/setup/addon-mcp.d.ts +2 -0
  57. package/dist/guest/setup/addon-mcp.js +49 -0
  58. package/dist/guest/setup/agent-addons.d.ts +9 -0
  59. package/dist/guest/setup/agent-addons.js +22 -0
  60. package/dist/guest/setup/headroom.d.ts +7 -0
  61. package/dist/guest/setup/headroom.js +60 -0
  62. package/dist/guest/setup/managed-policy.d.ts +2 -1
  63. package/dist/guest/setup/managed-policy.js +15 -6
  64. package/dist/guest/toolchains/plan.js +8 -3
  65. package/dist/host/audit/attribute.d.ts +46 -0
  66. package/dist/host/audit/attribute.js +76 -0
  67. package/dist/host/audit/collect.d.ts +29 -3
  68. package/dist/host/audit/collect.js +256 -61
  69. package/dist/host/audit/git-commits.d.ts +29 -0
  70. package/dist/host/audit/git-commits.js +157 -0
  71. package/dist/host/audit/index.d.ts +5 -0
  72. package/dist/host/audit/index.js +6 -0
  73. package/dist/host/audit/log.d.ts +44 -7
  74. package/dist/host/audit/log.js +148 -45
  75. package/dist/host/audit/redact.d.ts +2 -0
  76. package/dist/host/audit/redact.js +67 -0
  77. package/dist/runtime/agent.js +110 -15
  78. package/dist/runtime/daemon.js +569 -316
  79. package/dist/runtime/db.js +110 -15
  80. package/dist/runtime/main.js +1011 -672
  81. package/dist/runtime/netproxy.js +110 -15
  82. package/dist/runtime/view.js +110 -15
  83. package/dist/selftest/app.test.js +25 -1
  84. package/dist/selftest/audit.test.js +354 -47
  85. package/dist/selftest/hcs.test.js +35 -0
  86. package/dist/selftest/menu-screens.js +1 -1
  87. package/dist/selftest/menu.test.js +3 -3
  88. package/dist/selftest/support/engine-golden.js +9 -0
  89. package/dist/selftest/support/fixtures.d.ts +1 -0
  90. package/dist/selftest/support/web-preview.js +94 -0
  91. package/dist/selftest/toolchains.test.js +147 -2
  92. package/dist/shared/apps/agent-addons.d.ts +64 -0
  93. package/dist/shared/apps/agent-addons.js +95 -0
  94. package/dist/shared/apps/catalogue.d.ts +33 -2
  95. package/dist/shared/apps/catalogue.js +102 -11
  96. package/dist/shared/apps/index.d.ts +3 -2
  97. package/dist/shared/apps/index.js +3 -2
  98. package/dist/shared/apps/selection.d.ts +2 -0
  99. package/dist/shared/apps/selection.js +7 -0
  100. package/dist/sync/watcher.js +9 -1
  101. package/dist/web/index.html +2 -2
  102. package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
  103. package/dist/web/{main-K2JMCAZI.js → main-UD4QCXZR.js} +66 -66
  104. package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
  105. package/native/csharp/HcsHost/Hcs.cs +9 -0
  106. package/native/csharp/HcsHost/Program.cs +26 -1
  107. package/native/csharp/HcsHost/Share.cs +173 -0
  108. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  109. package/package.json +1 -1
  110. package/sandbox/addons/ccusage/package-lock.json +111 -0
  111. package/sandbox/addons/ccusage/package.json +7 -0
  112. package/sandbox/addons/context7/package-lock.json +1313 -0
  113. package/sandbox/addons/context7/package.json +7 -0
  114. package/sandbox/addons/headroom/requirements.in +1 -0
  115. package/sandbox/addons/headroom/requirements.txt +2922 -0
  116. package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
  117. package/sandbox/addons/playwright-mcp/package.json +7 -0
  118. package/schemas/config.schema.json +13 -4
  119. package/schemas/sandbox-config.schema.json +2 -2
  120. package/dist/cli/commands/maintenance/audit.d.ts +0 -3
  121. package/dist/cli/commands/maintenance/audit.js +0 -58
@@ -5,29 +5,55 @@ declare const AuditSettingsSchema: z.ZodObject<{
5
5
  maxMb: z.ZodNumber;
6
6
  processes: z.ZodBoolean;
7
7
  files: z.ZodBoolean;
8
+ commits: z.ZodDefault<z.ZodBoolean>;
8
9
  projects: z.ZodArray<z.ZodString>;
9
10
  }, z.core.$strip>;
10
11
  export type AuditSettings = z.output<typeof AuditSettingsSchema>;
11
12
  /** Host-only: the audit settings of the running sandbox (written at launch, read by the watcher). */
12
13
  export declare function saveAuditSettings(storageRoot: string, dataPath: string, settings: AuditSettings): void;
13
14
  export declare function readAuditSettings(storageRoot: string, dataPath: string): AuditSettings | null;
15
+ /** A project of the running sandbox: its name (as sessions name it) and its folder on this PC. */
16
+ export interface AuditProject {
17
+ readonly name: string;
18
+ readonly host: string;
19
+ }
14
20
  export declare class AuditCollector {
15
21
  private readonly dataPath;
16
22
  private readonly settings;
23
+ private readonly projects;
17
24
  private readonly log;
18
25
  private pending;
19
26
  private sessions;
27
+ private readonly book;
28
+ private gitRuns;
29
+ private held;
30
+ private readonly commitWatcher;
31
+ private readonly watched;
20
32
  private watchers;
21
- constructor(dataPath: string, storageRoot: string, settings: AuditSettings);
33
+ private maintainedOn;
34
+ constructor(dataPath: string, storageRoot: string, settings: AuditSettings, projects?: () => readonly AuditProject[]);
22
35
  private add;
23
- /** Starts watching the projects' files (when audit.files). */
36
+ /** Deletes old days, then starts watching the projects' files and commits. */
24
37
  start(): void;
38
+ /** The projects not watched yet (one that joined the sandbox since): their files and their reflogs from now on. */
39
+ private watchProjects;
40
+ private watchFiles;
41
+ private linked;
25
42
  /** Programs the sandbox reported: taken in (checked) and deleted. */
26
43
  private takeGuestReports;
44
+ /** One line the sandbox wrote: only a program, with only the fields it may set. */
45
+ private takeLine;
46
+ private primaryProject;
27
47
  private trackSessions;
48
+ /** New commits are held a moment; held ones whose time has come are written, linked to a session if one is known. */
49
+ private takeCommits;
50
+ /** File changes not linked yet: to the one session running in their project then, if there was exactly one. */
51
+ private linkFiles;
28
52
  guard(detail: string): void;
53
+ /** Old days go when the watcher starts and again whenever the day changes while it runs. */
54
+ private maintainDaily;
29
55
  /** Writes what came in since the last call (repeats folded); call every few seconds. */
30
- flush(): void;
56
+ flush(final?: boolean): void;
31
57
  stop(): void;
32
58
  maintain(): void;
33
59
  }
@@ -1,26 +1,68 @@
1
1
  // What goes into the audit log, from the background watcher while the sandbox runs:
2
2
  // - programs started in the sandbox: the .jsonl files it leaves in run\audit\ (untrusted: links refused, each file
3
- // and line size-capped, every line checked and forced to kind "proc"), taken in and deleted;
3
+ // and line size-capped, every line checked and forced to kind "proc", times kept to now, command lines scrubbed of
4
+ // secrets, a session it names kept only when this sandbox has that session), taken in and deleted;
4
5
  // - files changed in the projects: watched from this PC (the sandbox cannot hide or fake these);
6
+ // - git commits in the projects: read from each project's reflog on this PC (git-commits.ts);
5
7
  // - sessions starting and ending, and what the repo guard caught.
8
+ // Each event is linked to the agent session that did it where that can be known (attribute.ts), else to none.
6
9
  import fs from 'node:fs';
7
10
  import path from 'node:path';
8
11
  import { z } from 'zod';
9
12
  import { assertNoLinks } from '../../core/util/index.js';
10
13
  import { RunDir } from '../../shared/channel/index.js';
11
14
  import { JsonFile } from '../../shared/io/index.js';
15
+ import { hubName } from '../../shared/paths/index.js';
16
+ // The file itself, not the barrel: the barrel leads back to the watcher, which imports this file.
17
+ import { ProjectRegistry } from '../projects/registry.js';
12
18
  import { hostStateFile } from '../storage/index.js';
13
- import { AuditEventSchema, AuditLog, fold } from './log.js';
19
+ import { makesCommits, SessionBook } from './attribute.js';
20
+ import { CommitWatcher } from './git-commits.js';
21
+ import { AuditEventSchema, AuditLog, AuditSessionIdSchema, fold } from './log.js';
22
+ import { redactCommandLine } from './redact.js';
14
23
  const MAX_FILES_PER_PASS = 200;
15
24
  const MAX_FILE_BYTES = 8 * 1024 * 1024;
25
+ /** Most of the sandbox's reports taken in per pass, all files together. */
26
+ const MAX_BYTES_PER_PASS = 16 * 1024 * 1024;
27
+ /** Commits waiting to be written, and git commands remembered for linking them, at most. */
28
+ const MAX_HELD_COMMITS = 500;
29
+ const MAX_GIT_RUNS = 2000;
30
+ /** A plain file's text (null when it is not a plain file or is bigger than `max`), checked on the open handle. */
31
+ function readPlainFile(file, max) {
32
+ if (!fs.lstatSync(file).isFile())
33
+ return null;
34
+ const fd = fs.openSync(file, 'r');
35
+ try {
36
+ const stat = fs.fstatSync(fd);
37
+ if (!stat.isFile() || stat.size > max)
38
+ return null;
39
+ const buffer = Buffer.alloc(stat.size);
40
+ const read = fs.readSync(fd, buffer, 0, stat.size, 0);
41
+ return buffer.subarray(0, read).toString('utf8');
42
+ }
43
+ finally {
44
+ fs.closeSync(fd);
45
+ }
46
+ }
16
47
  /** Paths in a project not worth a line each (git's own bookkeeping, dependency folders). */
17
48
  const QUIET = /(^|[\\/])(\.git[\\/](objects|logs|refs|index\.lock|FETCH_HEAD|ORIG_HEAD)|node_modules[\\/]|target[\\/]|\.venv[\\/]|__pycache__[\\/])/i;
49
+ /** A commit waits this long before it is written, so the git command that made it (reported later) can claim it. */
50
+ const COMMIT_HOLD_MS = 45 * 1000;
51
+ /** Git commands are remembered this long for linking commits. */
52
+ const GIT_RUNS_KEPT_MS = 10 * 60 * 1000;
53
+ /** A time the sandbox reports is believed only this far from now (else it is now). */
54
+ const PAST_MS = 2 * 86400000;
55
+ const FUTURE_MS = 3600000;
56
+ /** Projects watched at most (the running sandbox's registry can grow while it runs). */
57
+ const MAX_PROJECTS = 64;
18
58
  const AuditSettingsSchema = z.object({
19
59
  enabled: z.boolean(),
20
60
  days: z.number().int().min(1).max(365),
21
61
  maxMb: z.number().int().min(5).max(5000),
22
62
  processes: z.boolean(),
23
63
  files: z.boolean(),
64
+ /** Missing in files written by versions before commits were recorded. */
65
+ commits: z.boolean().default(true),
24
66
  projects: z.array(z.string().max(1024)).max(64),
25
67
  });
26
68
  const settingsFile = (storageRoot, dataPath) => new JsonFile(AuditSettingsSchema, hostStateFile(storageRoot, dataPath, '-audit.json'));
@@ -31,51 +73,109 @@ export function saveAuditSettings(storageRoot, dataPath, settings) {
31
73
  export function readAuditSettings(storageRoot, dataPath) {
32
74
  return settingsFile(storageRoot, dataPath).read();
33
75
  }
76
+ /** The sandbox's projects: the registry's (projects that joined while it runs too) and the ones it started with. */
77
+ function registryProjects(storageRoot, started) {
78
+ const out = [];
79
+ const add = (name, host) => {
80
+ if (!out.some((p) => p.host.toLowerCase() === host.toLowerCase()))
81
+ out.push({ name, host });
82
+ };
83
+ try {
84
+ for (const p of new ProjectRegistry(storageRoot).read()?.projects ?? [])
85
+ add(p.name, p.host);
86
+ }
87
+ catch {
88
+ // No registry (or an unreadable one): the projects it started with.
89
+ }
90
+ for (const host of started)
91
+ add(hubName(host, []), host);
92
+ return out.slice(0, MAX_PROJECTS);
93
+ }
94
+ /** A time from the sandbox as an ISO time near now: one too far off (a wrong clock, a made-up line) becomes now. */
95
+ function believable(t, now) {
96
+ const ms = Date.parse(t);
97
+ return Number.isNaN(ms) || ms < now - PAST_MS || ms > now + FUTURE_MS
98
+ ? new Date(now).toISOString()
99
+ : new Date(ms).toISOString();
100
+ }
34
101
  export class AuditCollector {
35
102
  dataPath;
36
103
  settings;
104
+ projects;
37
105
  log;
38
106
  pending = [];
39
107
  sessions = new Map();
108
+ book = new SessionBook();
109
+ gitRuns = [];
110
+ held = [];
111
+ commitWatcher = new CommitWatcher();
112
+ watched = new Set();
40
113
  watchers = [];
41
- constructor(dataPath, storageRoot, settings) {
114
+ maintainedOn = '';
115
+ constructor(dataPath, storageRoot, settings, projects = () => registryProjects(storageRoot, settings.projects)) {
42
116
  this.dataPath = dataPath;
43
117
  this.settings = settings;
118
+ this.projects = projects;
44
119
  this.log = new AuditLog(AuditLog.dirFor(storageRoot, dataPath), settings);
45
120
  }
46
121
  add(e) {
47
122
  this.pending.push({ t: new Date().toISOString(), ...e });
48
123
  }
49
- /** Starts watching the projects' files (when audit.files). */
124
+ /** Deletes old days, then starts watching the projects' files and commits. */
50
125
  start() {
126
+ this.maintainDaily();
51
127
  this.add({
52
128
  k: 'note',
53
129
  p: 'sandbox started; audit: ' +
54
- [this.settings.processes && 'programs', this.settings.files && 'files', 'sessions'].filter(Boolean).join(', '),
130
+ [
131
+ this.settings.processes && 'programs',
132
+ this.settings.files && 'files',
133
+ this.settings.commits && 'commits',
134
+ 'sessions',
135
+ ]
136
+ .filter(Boolean)
137
+ .join(', '),
55
138
  });
56
- if (!this.settings.files)
57
- return;
58
- for (const project of this.settings.projects) {
59
- try {
60
- this.watchers.push(fs.watch(project, { recursive: true }, (event, file) => {
61
- const rel = file ? String(file) : '';
62
- if (!rel || QUIET.test(rel))
63
- return;
64
- this.add({
65
- k: 'file',
66
- a: event === 'rename' ? 'rename' : 'change',
67
- p: rel.replace(/\\/g, '/').slice(0, 1024),
68
- w: project,
69
- });
70
- }));
71
- }
72
- catch {
73
- /* a project that is gone */
74
- }
139
+ this.watchProjects();
140
+ }
141
+ /** The projects not watched yet (one that joined the sandbox since): their files and their reflogs from now on. */
142
+ watchProjects() {
143
+ for (const project of this.projects()) {
144
+ const key = project.host.toLowerCase();
145
+ if (this.watched.has(key))
146
+ continue;
147
+ this.watched.add(key);
148
+ if (this.settings.commits)
149
+ this.commitWatcher.prime([project.host]);
150
+ if (this.settings.files)
151
+ this.watchFiles(project);
152
+ }
153
+ }
154
+ watchFiles(project) {
155
+ try {
156
+ this.watchers.push(fs.watch(project.host, { recursive: true }, (event, file) => {
157
+ const rel = file ? String(file) : '';
158
+ if (!rel || QUIET.test(rel))
159
+ return;
160
+ this.add({
161
+ k: 'file',
162
+ a: event === 'rename' ? 'rename' : 'change',
163
+ p: rel.replace(/\\/g, '/').slice(0, 1024),
164
+ w: project.host,
165
+ pj: project.name,
166
+ });
167
+ }));
75
168
  }
169
+ catch {
170
+ /* a project that is gone */
171
+ }
172
+ }
173
+ linked(link) {
174
+ const s = this.book.get(link?.s);
175
+ return link && s ? { s: s.id, ag: s.agent, pj: s.project, sv: link.sv } : {};
76
176
  }
77
177
  /** Programs the sandbox reported: taken in (checked) and deleted. */
78
- takeGuestReports() {
178
+ takeGuestReports(now) {
79
179
  if (!this.settings.processes)
80
180
  return;
81
181
  const dir = new RunDir(this.dataPath).auditDir;
@@ -91,38 +191,80 @@ export class AuditCollector {
91
191
  catch {
92
192
  return;
93
193
  }
194
+ // A budget for the whole pass: what is over it waits (the next pass takes the next files).
195
+ let budget = MAX_BYTES_PER_PASS;
94
196
  for (const name of names) {
95
197
  const file = path.join(dir, name);
96
198
  try {
97
- const stat = fs.lstatSync(file);
98
- if (stat.isFile() && stat.size <= MAX_FILE_BYTES) {
99
- for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
100
- if (!line.trim() || line.length > 8192)
101
- continue;
102
- let raw;
103
- try {
104
- raw = JSON.parse(line);
105
- }
106
- catch {
107
- continue;
108
- }
109
- const e = AuditEventSchema.safeParse(raw);
110
- if (e.success)
111
- this.pending.push({ ...e.data, k: 'proc', w: undefined, a: undefined });
112
- }
199
+ const size = fs.lstatSync(file).size;
200
+ if (size <= MAX_FILE_BYTES && size > budget)
201
+ break;
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ try {
207
+ const text = readPlainFile(file, MAX_FILE_BYTES);
208
+ if (text !== null) {
209
+ budget -= text.length;
210
+ for (const line of text.split('\n'))
211
+ this.takeLine(line, now);
113
212
  }
114
213
  }
214
+ catch {
215
+ /* unreadable: deleted below like any other */
216
+ }
115
217
  finally {
116
- fs.rmSync(file, { force: true });
218
+ try {
219
+ fs.rmSync(file, { force: true, recursive: true });
220
+ }
221
+ catch {
222
+ // Cannot be removed (held open): the next pass tries again.
223
+ }
117
224
  }
118
225
  }
119
226
  }
120
- trackSessions() {
121
- const now = new Map(new RunDir(this.dataPath).listSessions().map((s) => [s.id, s.phase]));
122
- for (const s of new RunDir(this.dataPath).listSessions()) {
227
+ /** One line the sandbox wrote: only a program, with only the fields it may set. */
228
+ takeLine(line, now) {
229
+ if (!line.trim() || line.length > 8192)
230
+ return;
231
+ let raw;
232
+ try {
233
+ raw = JSON.parse(line);
234
+ }
235
+ catch {
236
+ return;
237
+ }
238
+ const e = AuditEventSchema.safeParse(raw);
239
+ if (!e.success)
240
+ return;
241
+ const { t, p, c, pp } = e.data;
242
+ const claimed = AuditSessionIdSchema.safeParse(e.data.s);
243
+ const session = claimed.success ? this.book.get(claimed.data) : undefined;
244
+ const event = {
245
+ t: believable(t, now),
246
+ k: 'proc',
247
+ ...(p !== undefined ? { p } : {}),
248
+ ...(c !== undefined ? { c: redactCommandLine(c) } : {}),
249
+ ...(pp !== undefined ? { pp } : {}),
250
+ ...(session ? this.linked({ s: session.id, sv: 'p' }) : {}),
251
+ };
252
+ if (session && makesCommits(p ?? '', c ?? '') && this.gitRuns.length < MAX_GIT_RUNS)
253
+ this.gitRuns.push({ t: Date.parse(event.t), session: session.id });
254
+ this.pending.push(event);
255
+ }
256
+ primaryProject() {
257
+ return this.projects()[0]?.name ?? 'project';
258
+ }
259
+ trackSessions(now) {
260
+ const list = new RunDir(this.dataPath).listSessions();
261
+ this.book.update(list, this.primaryProject(), now);
262
+ const phases = new Map(list.map((s) => [s.id, s.phase]));
263
+ for (const s of list) {
123
264
  const before = this.sessions.get(s.id);
265
+ const who = this.linked({ s: s.id, sv: 'p' });
124
266
  if (!before && s.phase === 'running')
125
- this.add({ k: 'session', a: 'start', p: s.harness, w: s.cwd, c: s.id });
267
+ this.add({ k: 'session', a: 'start', p: s.harness, w: s.cwd, c: s.id, ...who, sv: undefined });
126
268
  if (before === 'running' && (s.phase === 'exited' || s.phase === 'failed'))
127
269
  this.add({
128
270
  k: 'session',
@@ -130,35 +272,88 @@ export class AuditCollector {
130
272
  p: s.harness,
131
273
  w: s.cwd,
132
274
  c: s.id + (s.exitCode !== undefined ? ' exit ' + s.exitCode : ''),
275
+ ...who,
276
+ sv: undefined,
133
277
  });
134
278
  }
135
- this.sessions = now;
279
+ this.sessions = phases;
280
+ }
281
+ /** New commits are held a moment; held ones whose time has come are written, linked to a session if one is known. */
282
+ takeCommits(now, final) {
283
+ if (!this.settings.commits)
284
+ return;
285
+ const projects = this.projects();
286
+ // Commits are never folded, so their number is capped here (a reflog full of made-up lines cannot flood the log).
287
+ const fresh = this.commitWatcher.commits(projects.map((p) => p.host));
288
+ const room = Math.max(0, MAX_HELD_COMMITS - this.held.length);
289
+ for (const commit of fresh.slice(0, room))
290
+ this.held.push({ commit, seenAt: now });
291
+ if (fresh.length > room)
292
+ this.add({ k: 'note', p: fresh.length - room + ' more commits were seen at once and not listed (audit cap)' });
293
+ const due = this.held.filter((h) => final || now - h.seenAt >= COMMIT_HOLD_MS);
294
+ this.held = this.held.filter((h) => !due.includes(h));
295
+ for (const { commit, seenAt } of due) {
296
+ const name = projects.find((p) => p.host.toLowerCase() === commit.project.toLowerCase())?.name ??
297
+ hubName(commit.project, []);
298
+ // Git's own time, unless the sandbox's clock (or the line) is far off: then when it was seen.
299
+ const t = believable(commit.t, seenAt);
300
+ this.pending.push({
301
+ t,
302
+ k: 'commit',
303
+ a: commit.action,
304
+ p: redactCommandLine(commit.message),
305
+ h: commit.sha,
306
+ au: commit.author,
307
+ w: commit.project,
308
+ pj: name,
309
+ // With programs recorded, a commit no session's git command made is not put on a session as "likely" (it
310
+ // may well be yours, made on this PC).
311
+ ...this.linked(this.book.linkCommit(name, Date.parse(t), this.gitRuns, !this.settings.processes)),
312
+ });
313
+ }
314
+ this.gitRuns = this.gitRuns.filter((g) => g.t >= now - GIT_RUNS_KEPT_MS);
315
+ }
316
+ /** File changes not linked yet: to the one session running in their project then, if there was exactly one. */
317
+ linkFiles() {
318
+ this.pending = this.pending.map((e) => e.k === 'file' && !e.s && e.pj ? { ...e, ...this.linked(this.book.linkFile(e.pj, Date.parse(e.t))) } : e);
136
319
  }
137
320
  guard(detail) {
138
321
  this.add({ k: 'guard', p: detail.slice(0, 1024) });
139
322
  }
323
+ /** Old days go when the watcher starts and again whenever the day changes while it runs. */
324
+ maintainDaily() {
325
+ const today = new Date().toISOString().slice(0, 10);
326
+ if (today === this.maintainedOn)
327
+ return;
328
+ this.maintainedOn = today;
329
+ this.log.maintain(today);
330
+ }
140
331
  /** Writes what came in since the last call (repeats folded); call every few seconds. */
141
- flush() {
142
- try {
143
- this.takeGuestReports();
144
- }
145
- catch {
146
- /* next pass */
147
- }
148
- try {
149
- this.trackSessions();
150
- }
151
- catch {
152
- /* next pass */
153
- }
332
+ flush(final = false) {
333
+ const now = Date.now();
334
+ const step = (work) => {
335
+ try {
336
+ work();
337
+ }
338
+ catch {
339
+ /* next pass */
340
+ }
341
+ };
342
+ step(() => this.watchProjects());
343
+ // Sessions first: a program or commit can only be linked to a session the book knows.
344
+ step(() => this.trackSessions(now));
345
+ step(() => this.takeGuestReports(now));
346
+ step(() => this.takeCommits(now, final));
347
+ step(() => this.linkFiles());
154
348
  const events = this.pending.sort((a, b) => a.t.localeCompare(b.t));
155
349
  this.pending = [];
156
350
  this.log.append(fold(events));
351
+ step(() => this.maintainDaily());
157
352
  }
158
353
  stop() {
159
354
  this.watchers.forEach((w) => w.close());
160
355
  this.add({ k: 'note', p: 'sandbox closed' });
161
- this.flush();
356
+ this.flush(true);
162
357
  this.log.maintain();
163
358
  }
164
359
  maintain() {
@@ -0,0 +1,29 @@
1
+ /** One commit as the reflog records it. */
2
+ export interface CommitSeen {
3
+ /** ISO time git wrote. */
4
+ readonly t: string;
5
+ readonly sha: string;
6
+ /** Who made it, as git names them ("Name <email>"). */
7
+ readonly author: string;
8
+ /** commit | amend | merge | cherry-pick | revert | rebase | initial. */
9
+ readonly action: string;
10
+ /** The first line of its message. */
11
+ readonly message: string;
12
+ /** The project folder on this PC. */
13
+ readonly project: string;
14
+ }
15
+ /** A reflog line as a commit, or null when it is not one (a checkout, a reset, a pull without a merge commit ...). */
16
+ export declare function parseReflogLine(line: string, project: string): CommitSeen | null;
17
+ /** Follows the projects' reflogs: commits() returns the commits added since the last call. */
18
+ export declare class CommitWatcher {
19
+ /** Reflog file -> bytes already read. */
20
+ private readonly offsets;
21
+ /** The reflogs of a project: the main one and each worktree's. */
22
+ private reflogs;
23
+ /** Starts from what is there now: only commits made from here on are reported. */
24
+ prime(projects: readonly string[]): void;
25
+ private sizeOf;
26
+ /** The commits added to the projects' reflogs since the last call. A reflog seen for the first time is primed. */
27
+ commits(projects: readonly string[]): CommitSeen[];
28
+ private readRange;
29
+ }
@@ -0,0 +1,157 @@
1
+ // audit.commits: the git commits made in the projects while the sandbox runs, seen from this PC. Git appends a line
2
+ // to <project>\.git\logs\HEAD (and to each worktree's) for every commit; only what was added since the last look is
3
+ // read, a bounded amount each time. The sandbox can write these files (the project is mapped into it), so they are
4
+ // read as untrusted text: no links followed, sizes capped, every line checked, and a reflog that shrank is not read
5
+ // again from the start (that would report old commits as new).
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ /** Most new bytes read from one reflog per look (the rest waits for the next look). */
9
+ const MAX_READ = 256 * 1024;
10
+ /** A reflog bigger than this is not read at all (it is not a project's). */
11
+ const MAX_REFLOG = 512 * 1024 * 1024;
12
+ const MAX_LINE = 4096;
13
+ /** Worktrees of one project looked at, at most. */
14
+ const MAX_WORKTREES = 32;
15
+ const LINE = /^([0-9a-f]{40,64}) ([0-9a-f]{40,64}) (.{0,300}?) (\d{1,12}) ([+-]\d{4})\t(.*)$/;
16
+ /** The reflog messages that are new commits: what made it, and its subject. */
17
+ const COMMIT = /^(commit(?: \((initial|amend|merge)\))?|cherry-pick|revert|merge [^:]{1,200}|rebase(?: -i)? \((pick|reword|squash|fixup|edit)\)): ?(.*)$/;
18
+ /** A reflog line as a commit, or null when it is not one (a checkout, a reset, a pull without a merge commit ...). */
19
+ export function parseReflogLine(line, project) {
20
+ const m = LINE.exec(line);
21
+ if (!m)
22
+ return null;
23
+ const c = COMMIT.exec(m[6]);
24
+ if (!c || /^0+$/.test(m[2]))
25
+ return null;
26
+ const action = c[2] ?? (c[1].startsWith('merge') ? 'merge' : c[1].startsWith('rebase') ? 'rebase' : c[1]);
27
+ const seconds = Number(m[4]);
28
+ const time = new Date(seconds * 1000);
29
+ if (Number.isNaN(time.getTime()))
30
+ return null;
31
+ return {
32
+ t: time.toISOString(),
33
+ sha: m[2],
34
+ author: m[3].slice(0, 256),
35
+ action: action === 'commit' || action === 'initial' ? action : action.slice(0, 32),
36
+ message: (c[4] || '').slice(0, 1024),
37
+ project,
38
+ };
39
+ }
40
+ /** Is this path a plain folder or file (not a link), every step from `root` down? */
41
+ function plainPath(root, target, kind) {
42
+ const rel = path.relative(root, target);
43
+ if (rel.startsWith('..') || path.isAbsolute(rel))
44
+ return false;
45
+ let current = root;
46
+ const parts = rel.split(path.sep).filter(Boolean);
47
+ try {
48
+ if (fs.lstatSync(current).isSymbolicLink())
49
+ return false;
50
+ for (let i = 0; i < parts.length; i++) {
51
+ current = path.join(current, parts[i]);
52
+ const stat = fs.lstatSync(current);
53
+ if (stat.isSymbolicLink())
54
+ return false;
55
+ const last = i === parts.length - 1;
56
+ if (last ? (kind === 'file' ? !stat.isFile() : !stat.isDirectory()) : !stat.isDirectory())
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /** Follows the projects' reflogs: commits() returns the commits added since the last call. */
66
+ export class CommitWatcher {
67
+ /** Reflog file -> bytes already read. */
68
+ offsets = new Map();
69
+ /** The reflogs of a project: the main one and each worktree's. */
70
+ reflogs(project) {
71
+ const git = path.join(project, '.git');
72
+ if (!plainPath(project, git, 'dir'))
73
+ return [];
74
+ const files = [path.join(git, 'logs', 'HEAD')];
75
+ const worktrees = path.join(git, 'worktrees');
76
+ if (plainPath(project, worktrees, 'dir'))
77
+ try {
78
+ for (const name of fs.readdirSync(worktrees).slice(0, MAX_WORKTREES))
79
+ files.push(path.join(worktrees, name, 'logs', 'HEAD'));
80
+ }
81
+ catch {
82
+ /* no worktrees to read */
83
+ }
84
+ return files.filter((f) => plainPath(project, f, 'file'));
85
+ }
86
+ /** Starts from what is there now: only commits made from here on are reported. */
87
+ prime(projects) {
88
+ for (const project of projects)
89
+ for (const file of this.reflogs(project))
90
+ if (!this.offsets.has(file))
91
+ this.offsets.set(file, this.sizeOf(file));
92
+ }
93
+ sizeOf(file) {
94
+ try {
95
+ return fs.lstatSync(file).size;
96
+ }
97
+ catch {
98
+ return 0;
99
+ }
100
+ }
101
+ /** The commits added to the projects' reflogs since the last call. A reflog seen for the first time is primed. */
102
+ commits(projects) {
103
+ const found = [];
104
+ for (const project of projects)
105
+ for (const file of this.reflogs(project)) {
106
+ const size = this.sizeOf(file);
107
+ const done = this.offsets.get(file);
108
+ // New (a worktree made since): from its end. Shrunk (expired or rewritten): from its new end.
109
+ if (done === undefined || size < done || size > MAX_REFLOG) {
110
+ this.offsets.set(file, size);
111
+ continue;
112
+ }
113
+ if (size === done)
114
+ continue;
115
+ const length = Math.min(size - done, MAX_READ);
116
+ const bytes = this.readRange(file, done, length);
117
+ if (bytes === null)
118
+ continue;
119
+ // Only whole lines (counted in bytes); a line cut by the read limit is read next time, one longer than the
120
+ // limit is skipped.
121
+ const end = bytes.lastIndexOf(0x0a);
122
+ if (end < 0) {
123
+ if (length === MAX_READ)
124
+ this.offsets.set(file, done + length);
125
+ continue;
126
+ }
127
+ this.offsets.set(file, done + end + 1);
128
+ for (const line of bytes.subarray(0, end).toString('utf8').split('\n')) {
129
+ if (line.length > MAX_LINE)
130
+ continue;
131
+ const commit = parseReflogLine(line.replace(/\r$/, ''), project);
132
+ if (commit)
133
+ found.push(commit);
134
+ }
135
+ }
136
+ return found;
137
+ }
138
+ readRange(file, from, length) {
139
+ let fd;
140
+ try {
141
+ fd = fs.openSync(file, 'r');
142
+ // What was opened must still be a plain file (not swapped for something else after the check).
143
+ if (!fs.fstatSync(fd).isFile())
144
+ return null;
145
+ const buffer = Buffer.alloc(length);
146
+ const read = fs.readSync(fd, buffer, 0, length, from);
147
+ return buffer.subarray(0, read);
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ finally {
153
+ if (fd !== undefined)
154
+ fs.closeSync(fd);
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,5 @@
1
+ export * from './attribute.js';
2
+ export * from './collect.js';
3
+ export * from './git-commits.js';
4
+ export * from './log.js';
5
+ export * from './redact.js';
@@ -0,0 +1,6 @@
1
+ // The audit log: what agents did in the sandbox (programs, files, commits, sessions, repo guard), kept on this PC.
2
+ export * from './attribute.js';
3
+ export * from './collect.js';
4
+ export * from './git-commits.js';
5
+ export * from './log.js';
6
+ export * from './redact.js';