windows-development-sandbox 1.11.6 → 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 (131) 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/projects/projects.def.js +1 -1
  18. package/dist/app/features/settings/settings.run.d.ts +3 -0
  19. package/dist/app/features/settings/settings.run.js +16 -2
  20. package/dist/app/features/updates/update-lines.d.ts +1 -1
  21. package/dist/app/features/updates/update-lines.js +3 -7
  22. package/dist/app/features/updates/updates.def.js +1 -1
  23. package/dist/app/kernel/define.d.ts +12 -1
  24. package/dist/app/registry.js +2 -0
  25. package/dist/cli/args.d.ts +0 -3
  26. package/dist/cli/args.js +3 -4
  27. package/dist/cli/commands/index.d.ts +1 -1
  28. package/dist/cli/commands/index.js +1 -1
  29. package/dist/cli/commands/launch/prepare.js +1 -1
  30. package/dist/cli/commands/maintenance/index.d.ts +0 -1
  31. package/dist/cli/commands/maintenance/index.js +0 -1
  32. package/dist/cli/commands/registry.js +1 -2
  33. package/dist/cli/manifest.d.ts +0 -41
  34. package/dist/cli/manifest.js +0 -14
  35. package/dist/cli/menu/pages/apps-page.js +2 -15
  36. package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
  37. package/dist/cli/menu/pages/maintenance-page.js +51 -8
  38. package/dist/cli/menu/views.js +2 -0
  39. package/dist/core/config/defaults.js +1 -1
  40. package/dist/core/schema/apps.d.ts +9 -0
  41. package/dist/core/schema/apps.js +9 -0
  42. package/dist/core/schema/config.d.ts +1 -0
  43. package/dist/core/schema/config.js +3 -3
  44. package/dist/core/schema/sandbox.d.ts +1 -0
  45. package/dist/core/schema/sandbox.js +11 -3
  46. package/dist/guest/apps/app-installer.js +50 -4
  47. package/dist/guest/apps/index.d.ts +1 -1
  48. package/dist/guest/apps/index.js +1 -1
  49. package/dist/guest/apps/sources.js +1 -1
  50. package/dist/guest/audit/parse.d.ts +32 -2
  51. package/dist/guest/audit/parse.js +43 -5
  52. package/dist/guest/audit/process-audit.d.ts +3 -1
  53. package/dist/guest/audit/process-audit.js +9 -6
  54. package/dist/guest/boot/boot.js +12 -3
  55. package/dist/guest/boot/guide.d.ts +1 -1
  56. package/dist/guest/boot/guide.js +27 -3
  57. package/dist/guest/daemon/sessions.d.ts +7 -0
  58. package/dist/guest/daemon/sessions.js +27 -2
  59. package/dist/guest/entries/daemon.js +2 -1
  60. package/dist/guest/setup/addon-mcp.d.ts +2 -0
  61. package/dist/guest/setup/addon-mcp.js +49 -0
  62. package/dist/guest/setup/agent-addons.d.ts +9 -0
  63. package/dist/guest/setup/agent-addons.js +22 -0
  64. package/dist/guest/setup/headroom.d.ts +7 -0
  65. package/dist/guest/setup/headroom.js +60 -0
  66. package/dist/guest/setup/managed-policy.d.ts +2 -1
  67. package/dist/guest/setup/managed-policy.js +15 -6
  68. package/dist/guest/toolchains/plan.js +8 -3
  69. package/dist/host/audit/attribute.d.ts +46 -0
  70. package/dist/host/audit/attribute.js +76 -0
  71. package/dist/host/audit/collect.d.ts +29 -3
  72. package/dist/host/audit/collect.js +256 -61
  73. package/dist/host/audit/git-commits.d.ts +29 -0
  74. package/dist/host/audit/git-commits.js +157 -0
  75. package/dist/host/audit/index.d.ts +5 -0
  76. package/dist/host/audit/index.js +6 -0
  77. package/dist/host/audit/log.d.ts +44 -7
  78. package/dist/host/audit/log.js +148 -45
  79. package/dist/host/audit/redact.d.ts +2 -0
  80. package/dist/host/audit/redact.js +67 -0
  81. package/dist/host/update/decision.d.ts +3 -2
  82. package/dist/host/update/decision.js +2 -3
  83. package/dist/host/update/updater.d.ts +4 -1
  84. package/dist/host/update/updater.js +11 -6
  85. package/dist/runtime/agent.js +110 -15
  86. package/dist/runtime/daemon.js +569 -316
  87. package/dist/runtime/db.js +110 -15
  88. package/dist/runtime/main.js +1011 -672
  89. package/dist/runtime/netproxy.js +110 -15
  90. package/dist/runtime/view.js +110 -15
  91. package/dist/selftest/app.test.js +25 -1
  92. package/dist/selftest/audit.test.js +354 -47
  93. package/dist/selftest/hcs.test.js +35 -0
  94. package/dist/selftest/menu-screens.js +1 -1
  95. package/dist/selftest/menu.test.js +3 -3
  96. package/dist/selftest/support/engine-golden.js +9 -0
  97. package/dist/selftest/support/fixtures.d.ts +1 -0
  98. package/dist/selftest/support/web-preview.js +94 -0
  99. package/dist/selftest/toolchains.test.js +147 -2
  100. package/dist/selftest/update-service.test.js +0 -7
  101. package/dist/selftest/update.test.js +4 -4
  102. package/dist/shared/apps/agent-addons.d.ts +64 -0
  103. package/dist/shared/apps/agent-addons.js +95 -0
  104. package/dist/shared/apps/catalogue.d.ts +33 -2
  105. package/dist/shared/apps/catalogue.js +102 -11
  106. package/dist/shared/apps/index.d.ts +3 -2
  107. package/dist/shared/apps/index.js +3 -2
  108. package/dist/shared/apps/selection.d.ts +2 -0
  109. package/dist/shared/apps/selection.js +7 -0
  110. package/dist/sync/watcher.js +9 -1
  111. package/dist/web/index.html +2 -2
  112. package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
  113. package/dist/web/{main-QL7KHCEE.js → main-UD4QCXZR.js} +66 -66
  114. package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
  115. package/native/csharp/HcsHost/Hcs.cs +9 -0
  116. package/native/csharp/HcsHost/Program.cs +26 -1
  117. package/native/csharp/HcsHost/Share.cs +173 -0
  118. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  119. package/package.json +1 -1
  120. package/sandbox/addons/ccusage/package-lock.json +111 -0
  121. package/sandbox/addons/ccusage/package.json +7 -0
  122. package/sandbox/addons/context7/package-lock.json +1313 -0
  123. package/sandbox/addons/context7/package.json +7 -0
  124. package/sandbox/addons/headroom/requirements.in +1 -0
  125. package/sandbox/addons/headroom/requirements.txt +2922 -0
  126. package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
  127. package/sandbox/addons/playwright-mcp/package.json +7 -0
  128. package/schemas/config.schema.json +13 -4
  129. package/schemas/sandbox-config.schema.json +2 -2
  130. package/dist/cli/commands/maintenance/audit.d.ts +0 -3
  131. package/dist/cli/commands/maintenance/audit.js +0 -58
@@ -1,13 +1,19 @@
1
1
  // The audit log: kept small (repeats folded, a cap per minute, old days compressed, limits on days and size), read back
2
- // filtered, and what the sandbox reports is checked like everything else that comes from it.
2
+ // filtered, secrets scrubbed from command lines, every program linked to the session that started it (and nothing
3
+ // guessed), commits read from a project's reflog, what the sandbox reports checked like everything else from it, and
4
+ // the Activity page on every surface.
3
5
  import crypto from 'node:crypto';
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
6
8
  import zlib from 'node:zlib';
7
- import { AuditCollector } from '../host/audit/collect.js';
8
- import { AuditLog, fold } from '../host/audit/log.js';
9
- import { parseProcessEvents } from '../guest/audit/parse.js';
9
+ import { auditArgv, auditPage, auditView, listAudit, NO_SESSION } from '../app/features/audit/index.js';
10
+ import { auditListing } from '../app/features/audit/audit.run.js';
11
+ import { pageLines } from '../cli/output/index.js';
12
+ import { menuView } from '../cli/menu/render-view.js';
13
+ import { AuditCollector, AuditLog, CommitWatcher, fold, makesCommits, parseReflogLine, redactCommandLine, SessionBook, } from '../host/audit/index.js';
14
+ import { Attributor, parseProcessEvents } from '../guest/audit/parse.js';
10
15
  import { RunDir } from '../shared/channel/index.js';
16
+ const DAY = 86400000;
11
17
  const ev = (t, extra = {}) => ({
12
18
  t,
13
19
  k: 'proc',
@@ -15,9 +21,39 @@ const ev = (t, extra = {}) => ({
15
21
  c: 'cargo build',
16
22
  ...extra,
17
23
  });
24
+ /** YYYY-MM-DD, `n` days before today (UTC, like the day files). */
25
+ const day = (n) => new Date(Date.now() - n * DAY).toISOString().slice(0, 10);
26
+ const iso = (msAgo) => new Date(Date.now() - msAgo).toISOString();
27
+ const SHA = (c) => c.repeat(40);
28
+ const settings = (extra = {}) => ({
29
+ enabled: true,
30
+ days: 7,
31
+ maxMb: 50,
32
+ processes: true,
33
+ files: false,
34
+ commits: true,
35
+ projects: [],
36
+ ...extra,
37
+ });
38
+ /** A 4688 event as wevtutil prints it. */
39
+ function procEvent(id, pid, ppid, program, cmd = program, t = iso(1000)) {
40
+ return ("<Event><System><TimeCreated SystemTime='" +
41
+ t +
42
+ "'/><EventRecordID>" +
43
+ id +
44
+ "</EventRecordID></System><EventData><Data Name='NewProcessId'>0x" +
45
+ pid.toString(16) +
46
+ "</Data><Data Name='NewProcessName'>" +
47
+ program +
48
+ "</Data><Data Name='CommandLine'>" +
49
+ cmd +
50
+ "</Data><Data Name='ProcessId'>0x" +
51
+ ppid.toString(16) +
52
+ "</Data><Data Name='ParentProcessName'>C:\\parent.exe</Data></EventData></Event>");
53
+ }
18
54
  export const auditSuite = {
19
55
  name: 'audit',
20
- title: 'audit log (folding, compression, limits, untrusted reports)',
56
+ title: 'audit log (folding, retention, secrets, sessions, commits, untrusted reports, the Activity page)',
21
57
  run: (t) => {
22
58
  t.section('small');
23
59
  const folded = fold([
@@ -31,60 +67,331 @@ export const auditSuite = {
31
67
  ['cargo test', 1],
32
68
  ['cargo build', 1],
33
69
  ], 'repeats within a minute become one line with a count');
34
- const flood = fold(Array.from({ length: 50 }, (_, i) => ev('2026-01-01T10:00:00Z', { c: 'step ' + i })), 10);
35
- t.check(flood.length === 11 && flood[10].k === 'note' && flood[10].p.startsWith('40 more'), 'past the per-minute cap the rest is one note');
70
+ t.equal(fold([ev('2026-01-01T10:00:01Z', { s: 'a' }), ev('2026-01-01T10:00:02Z', { s: 'b' })]).length, 2, "the same command in two sessions stays two lines (each session's own)");
71
+ const flood = fold([
72
+ ...Array.from({ length: 50 }, (_, i) => ev('2026-01-01T10:00:00Z', { c: 'step ' + i })),
73
+ ev('2026-01-01T10:00:00Z', { k: 'commit', h: SHA('a'), p: 'a commit' }),
74
+ ], 10);
75
+ t.check(flood.length === 12 &&
76
+ flood.some((e) => e.k === 'commit') &&
77
+ flood.at(-1).k === 'note' &&
78
+ flood.at(-1).p.startsWith('40 more'), 'past the per-minute cap the rest is one note; a commit is never dropped');
79
+ t.section('retention: days and size');
36
80
  const dir = t.at('audit-log');
37
81
  const log = new AuditLog(dir, { days: 3, maxMb: 5 });
38
82
  log.append([
39
- ev('2026-01-01T10:00:00Z'),
40
- ev('2026-01-02T10:00:00Z'),
41
- ev('2026-01-04T10:00:00Z', { k: 'file', p: 'src/a.ts', a: 'change', w: 'C:\\app' }),
83
+ ev(day(4) + 'T10:00:00Z'),
84
+ ev(day(1) + 'T10:00:00Z'),
85
+ ev(day(0) + 'T00:00:01Z', { k: 'file', p: 'src/a.ts', a: 'change', w: 'C:\\app' }),
42
86
  ]);
43
- log.maintain('2026-01-04');
44
- t.equal(fs.readdirSync(dir).sort(), ['2026-01-02.jsonl.gz', '2026-01-04.jsonl'], 'finished days are compressed and days past audit.days are deleted');
45
- t.includes(zlib.gunzipSync(fs.readFileSync(path.join(dir, '2026-01-02.jsonl.gz'))).toString(), 'cargo build', 'a compressed day still holds its events');
46
- const big = new AuditLog(t.at('audit-big'), { days: 30, maxMb: 5 });
87
+ log.maintain(day(0));
88
+ t.equal(fs.readdirSync(dir).sort(), [day(1) + '.jsonl.gz', day(0) + '.jsonl'], 'finished days are compressed and days past audit.days are deleted');
89
+ t.includes(zlib.gunzipSync(fs.readFileSync(path.join(dir, day(1) + '.jsonl.gz'))).toString(), 'cargo build', 'a compressed day still holds its events');
90
+ // Written while the limit was higher, then kept to 5 MB.
91
+ const roomy = new AuditLog(t.at('audit-big'), { days: 30, maxMb: 50 });
47
92
  // Random text: compression must not make the test data fit after all.
48
- const line = (day) => ev(day + 'T00:00:00Z', { c: crypto.randomBytes(900).toString('hex') });
49
- for (const day of ['2026-02-01', '2026-02-02', '2026-02-03'])
50
- big.append(Array.from({ length: 1400 }, () => line(day)));
51
- big.maintain('2026-02-03');
93
+ const line = (d) => ev(d + 'T00:00:00Z', { c: crypto.randomBytes(900).toString('hex') });
94
+ for (const d of [day(2), day(1), day(0)])
95
+ roomy.append(Array.from({ length: 1400 }, () => line(d)));
96
+ const big = new AuditLog(roomy.dir, { days: 30, maxMb: 5 });
97
+ big.maintain(day(0));
52
98
  t.check(big.bytes() <= 5 * 1024 * 1024 && fs.readdirSync(big.dir).length < 3, 'past audit.maxMb the oldest days go', String(big.bytes()));
99
+ const capped = new AuditLog(t.at('audit-capped'), { days: 30, maxMb: 5 });
100
+ for (let i = 0; i < 3; i++)
101
+ capped.append(Array.from({ length: 1400 }, () => line(day(0))));
102
+ const cappedEvents = capped.read({ days: 1 });
103
+ t.check(capped.bytes() <= 5 * 1024 * 1024 + 4096 &&
104
+ cappedEvents.filter((e) => e.k === 'note' && e.p.includes('audit.maxMb')).length === 1, 'past audit.maxMb nothing more is written while the sandbox runs, but one note saying so', String(capped.bytes()));
105
+ const late = new AuditLog(t.at('audit-late'), { days: 7, maxMb: 5 });
106
+ late.append([ev(day(1) + 'T23:59:58Z', { c: 'before midnight' })]);
107
+ late.maintain(day(0));
108
+ late.append([ev(day(1) + 'T23:59:59Z', { c: 'reported late' })]);
109
+ late.maintain(day(0));
110
+ t.equal(late.read({ days: 3 }).map((e) => e.c), ['before midnight', 'reported late'], "a line that arrives after its day was compressed is added to it: the day's log is never written over");
111
+ const clipped = new AuditLog(t.at('audit-clip'), { days: 7, maxMb: 5 });
112
+ clipped.append([ev(day(0) + 'T00:00:01Z', { k: 'session', w: 'C:\\' + 'x'.repeat(3000) })]);
113
+ t.equal(clipped.read({ days: 1 }).map((e) => e.w?.length), [1024], 'a field longer than its limit is cut, not left to make the line unreadable later');
114
+ const kept = new AuditLog(t.at('audit-kept'), { days: 2, maxMb: 5 });
115
+ kept.append([ev(day(5) + 'T10:00:00Z', { c: 'old' }), ev(day(0) + 'T00:00:01Z', { c: 'new' })]);
116
+ t.equal(kept.read({ days: 30 }).map((e) => e.c), ['new'], 'reading never goes back past audit.days, even before the old day is deleted');
117
+ t.write(path.join(kept.dir, day(1) + '.jsonl.gz'), 'not gzip at all');
118
+ t.equal(kept.read({ days: 2 }).length, 1, 'a damaged day is left out, the rest is read');
53
119
  t.section('reading');
54
- t.equal(log.read({ days: 10000, kinds: ['file'] }).map((e) => e.p), ['src/a.ts'], 'events can be filtered by kind');
55
- t.equal(log.read({ days: 10000, grep: 'SRC/A.TS' }).length, 1, '... and by text (any case)');
120
+ t.equal(log.read({ days: 3, kinds: ['file'] }).map((e) => e.p), ['src/a.ts'], 'events can be filtered by kind');
121
+ t.equal(log.read({ days: 3, grep: 'SRC/A.TS' }).length, 1, '... and by text (any case)');
122
+ t.section('secrets never reach the log');
123
+ const cases = [
124
+ ['curl -H "Authorization: Bearer abcdefghijklmnopqrstuvwxyz" https://x', 'abcdefghijklmnop', 'a header'],
125
+ ['git push https://bob:hunter22@github.com/a/b.git', 'hunter22', 'a password in a URL'],
126
+ ['npm publish --token npm_abcdefghijklmnopqrstuvwxyz0123456789', 'npm_abcdefgh', 'a token flag'],
127
+ ['mytool --password=s3cretvalue --verbose', 's3cretvalue', '--password=value'],
128
+ ['set API_KEY=zzzyyyxxx123 && run', 'zzzyyyxxx123', 'NAME=value'],
129
+ ['mysql -u root -pHunter2pass db', 'Hunter2pass', 'mysql -p<password>'],
130
+ ['echo ghp_abcdefghijklmnopqrstuvwxyz0123456789AB', 'ghp_abcdefghij', 'a token by its shape'],
131
+ ["$env:GITHUB_TOKEN = 'abcdef123456'", 'abcdef123456', 'a PowerShell variable'],
132
+ ['set PGPASSWORD=pgsecret1 && psql', 'pgsecret1', 'a name with the word inside (PGPASSWORD)'],
133
+ ['tool --accessToken abc123def456', 'abc123def456', 'a camelCase flag'],
134
+ ['tool --apiToken=abc123def456', 'abc123def456', 'a camelCase flag with ='],
135
+ ['docker login -u me -p dockerpw1 registry.io', 'dockerpw1', 'docker login -p'],
136
+ ['sshpass -p sshpw123 ssh host', 'sshpw123', 'sshpass -p'],
137
+ ['curl --user=me:curlpw12 https://x', 'curlpw12', 'curl --user=name:password'],
138
+ ['git clone https://abcdef0123456789@github.com/a/b', 'abcdef0123456789', 'a token as the user name of a URL'],
139
+ ['$p = ConvertTo-SecureString "pspw1234" -AsPlainText', 'pspw1234', 'ConvertTo-SecureString'],
140
+ ];
141
+ for (const [cmd, secret, what] of cases) {
142
+ const out = redactCommandLine(cmd);
143
+ t.check(!out.includes(secret) && out.includes('***'), 'hidden: ' + what, out);
144
+ }
145
+ t.equal(redactCommandLine('git commit -m "fix the login page" && cargo build --release'), 'git commit -m "fix the login page" && cargo build --release', 'ordinary command lines stay as they are');
146
+ t.section('which session a program belongs to');
147
+ const attributor = new Attributor();
148
+ const now = Date.now();
149
+ const roots = [
150
+ { session: 's-one', pid: 100, from: now - 60000 },
151
+ { session: 's-old', pid: 300, from: now - 600000, to: now - 300000 },
152
+ ];
153
+ const who = (pid, ppid, at = now) => attributor.attribute({ pid, ppid, t: at }, roots);
154
+ t.equal([who(100, 50), who(101, 100), who(102, 101), who(103, 102)], [undefined, 's-one', 's-one', 's-one'], "the ptyhost itself is no session's; what it starts, and what those start, are its session's");
155
+ t.equal(who(200, 77), undefined, 'a program whose parent was never seen is no session: nothing is guessed');
156
+ t.equal(who(102, 999), undefined, 'a reused process id does not keep the session of the process it had before');
157
+ t.equal(who(104, 102), undefined, '... and neither do the programs it starts');
158
+ t.equal(who(301, 300), undefined, "an ended session's ptyhost id, reused later, is not that session's");
159
+ const xml = procEvent(41, 0x200, 0x100, 'C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force &quot;origin&quot;') +
160
+ procEvent(42, 0x201, 0x200, 'C:\\Windows\\System32\\conhost.exe') +
161
+ procEvent(43, 0x202, 0x201, 'C:\\x\\node.exe', 'node a.js');
162
+ const parsed = parseProcessEvents(xml, {
163
+ attributor: new Attributor(),
164
+ roots: [{ session: 's-two', pid: 0x100, from: now - 60000 }],
165
+ });
166
+ t.equal({
167
+ last: parsed.last,
168
+ lines: parsed.lines.map((l) => JSON.parse(l)).map((e) => [e.p, e.c, e.s]),
169
+ }, {
170
+ last: 43,
171
+ lines: [
172
+ ['C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force "origin"', 's-two'],
173
+ ['C:\\x\\node.exe', 'node a.js', 's-two'],
174
+ ],
175
+ }, "Windows process events become audit lines linked to their session; Windows' own programs are left out but still carry the link");
176
+ t.section('commits, read from the reflog on this PC');
177
+ const repo = t.dir('audit-repo');
178
+ const reflog = t.write(path.join(repo, '.git', 'logs', 'HEAD'), '');
179
+ const reflogLine = (sha, msg, secondsAgo = 5) => SHA('0') +
180
+ ' ' +
181
+ sha +
182
+ ' Ada Lovelace <ada@example.com> ' +
183
+ Math.floor((Date.now() - secondsAgo * 1000) / 1000) +
184
+ ' +0100\t' +
185
+ msg +
186
+ '\n';
187
+ fs.appendFileSync(reflog, reflogLine(SHA('1'), 'commit (initial): before the sandbox'));
188
+ const watcher = new CommitWatcher();
189
+ watcher.prime([repo]);
190
+ fs.appendFileSync(reflog, reflogLine(SHA('2'), 'commit: add the parser') +
191
+ reflogLine(SHA('3'), 'checkout: moving from main to dev') +
192
+ reflogLine(SHA('4'), 'commit (amend): add the parser, fixed') +
193
+ 'garbage line\n');
194
+ const seen = watcher.commits([repo]);
195
+ t.equal(seen.map((c) => [c.sha.slice(0, 1), c.action, c.message, c.author]), [
196
+ ['2', 'commit', 'add the parser', 'Ada Lovelace <ada@example.com>'],
197
+ ['4', 'amend', 'add the parser, fixed', 'Ada Lovelace <ada@example.com>'],
198
+ ], 'only commits made since the first look are reported (checkouts and junk are not commits)');
199
+ t.equal(watcher.commits([repo]), [], 'nothing new, nothing reported');
200
+ fs.writeFileSync(reflog, reflogLine(SHA('5'), 'commit: rewritten'));
201
+ t.equal(watcher.commits([repo]), [], 'a reflog that shrank is not read again from the start');
202
+ fs.mkdirSync(path.join(repo, '.git', 'worktrees', 'wt', 'logs'), { recursive: true });
203
+ const wt = t.write(path.join(repo, '.git', 'worktrees', 'wt', 'logs', 'HEAD'), '');
204
+ watcher.commits([repo]);
205
+ fs.appendFileSync(wt, reflogLine(SHA('6'), 'commit: in a worktree'));
206
+ fs.appendFileSync(reflog, reflogLine(SHA('7'), 'merge feature: Merge made by the ort strategy.'));
207
+ t.equal(watcher
208
+ .commits([repo])
209
+ .map((c) => c.action + ' ' + c.sha.slice(0, 1))
210
+ .sort(), ['commit 6', 'merge 7'], "a worktree's commits are seen too, and merges");
211
+ t.equal(parseReflogLine(SHA('0') + ' ' + SHA('0') + ' x <y> 1 +0000\tcommit: deleted', repo), null, 'a line that does not record a commit (all-zero id) is not one');
212
+ t.check(makesCommits('C:\\git\\cmd\\git.exe', 'git commit -m x') &&
213
+ makesCommits('git', 'git -C app merge dev') &&
214
+ !makesCommits('C:\\git\\cmd\\git.exe', 'git status') &&
215
+ !makesCommits('C:\\x\\node.exe', 'node commit.js'), 'the git commands that can make a commit are told apart from the rest');
216
+ t.section('files and commits: linked only when it can be known');
217
+ const book = new SessionBook();
218
+ const base = Date.now() - 3600000;
219
+ const at = (minutes) => new Date(base + minutes * 60000).toISOString();
220
+ book.update([
221
+ { id: 'a', harness: 'claude', project: 'app', phase: 'exited', startedAt: at(0), updatedAt: at(30) },
222
+ { id: 'b', harness: 'codex', project: 'app', phase: 'running', startedAt: at(20), updatedAt: at(20) },
223
+ { id: 'c', harness: 'claude', project: 'web', phase: 'running', startedAt: at(0), updatedAt: at(0) },
224
+ ], 'app');
225
+ const ms = (minutes) => base + minutes * 60000;
226
+ t.equal([
227
+ book.linkFile('app', ms(10)),
228
+ book.linkFile('app', ms(25)),
229
+ book.linkFile('app', ms(40)),
230
+ book.linkFile('other', ms(10)),
231
+ ], [{ s: 'a', sv: 't' }, undefined, { s: 'b', sv: 't' }, undefined], 'a file change is linked (as likely) only while exactly one session ran in its project');
232
+ t.equal([
233
+ book.linkCommit('app', ms(25), [{ t: ms(24), session: 'b' }]),
234
+ book.linkCommit('app', ms(25), [
235
+ { t: ms(24), session: 'a' },
236
+ { t: ms(24.5), session: 'b' },
237
+ ]),
238
+ book.linkCommit('app', ms(25), [{ t: ms(24), session: 'c' }]),
239
+ ], [{ s: 'b', sv: 'p' }, undefined, undefined], "a commit goes to the session whose git command made it; two candidates or another project's session: none");
240
+ t.equal([book.linkCommit('app', ms(40), [], true), book.linkCommit('app', ms(40), [], false)], [{ s: 'b', sv: 't' }, undefined], 'with no git command seen, a commit is "likely" the only running session only when programs are not recorded');
241
+ book.update([], 'app', ms(50));
242
+ t.equal(book.linkFile('app', ms(55)), undefined, 'a session whose record is gone counts as ended');
56
243
  t.section('what the sandbox reports');
57
- const xml = "<Event><System><TimeCreated SystemTime='2026-01-05T09:00:00.000Z'/><EventRecordID>41</EventRecordID></System><EventData><Data Name='NewProcessName'>C:\\AgentData\\tools\\git\\cmd\\git.exe</Data><Data Name='CommandLine'>git push --force &quot;origin&quot;</Data><Data Name='ParentProcessName'>C:\\nvm\\nodejs\\node.exe</Data></EventData></Event>" +
58
- "<Event><System><EventRecordID>42</EventRecordID></System><EventData><Data Name='NewProcessName'>C:\\Windows\\System32\\conhost.exe</Data></EventData></Event>";
59
- const parsed = parseProcessEvents(xml);
60
- t.equal({ last: parsed.last, lines: parsed.lines.map((l) => JSON.parse(l)).map((e) => [e.p, e.c, e.pp]) }, {
61
- last: 42,
62
- lines: [['C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force "origin"', 'C:\\nvm\\nodejs\\node.exe']],
63
- }, "Windows process events become audit lines; Windows' own background programs are left out");
64
244
  const data = t.at('audit-data', 'data', 'd');
65
245
  const storage = t.at('audit-data');
66
- const reports = new RunDir(data).auditDir;
67
- t.write(path.join(reports, '1-1.jsonl'), [
68
- JSON.stringify({ t: '2026-01-05T09:00:00Z', k: 'guard', p: 'fake: all clear' }),
69
- JSON.stringify({ t: '2026-01-05T09:00:01Z', k: 'proc', p: 'C:\\x.exe', c: 'x --y', w: 'C:\\Users\\me' }),
246
+ const run = new RunDir(data);
247
+ t.writeJson(run.sessionFile('known-1'), {
248
+ id: 'known-1',
249
+ launchId: 'l1',
250
+ harness: 'claude',
251
+ cwd: 'C:\\Projects\\app',
252
+ project: 'app',
253
+ phase: 'running',
254
+ message: 'claude is running',
255
+ updatedAt: iso(60000),
256
+ startedAt: iso(120000),
257
+ });
258
+ t.write(path.join(run.auditDir, '1-1.jsonl'), [
259
+ JSON.stringify({ t: iso(5000), k: 'guard', p: 'fake: all clear', s: 'known-1' }),
260
+ JSON.stringify({
261
+ t: iso(4000),
262
+ k: 'proc',
263
+ p: 'C:\\x.exe',
264
+ c: 'x --token abcdefgh12345678',
265
+ w: 'C:\\Users\\me',
266
+ s: 'known-1',
267
+ ag: 'not-claude',
268
+ }),
269
+ JSON.stringify({ t: '2001-01-01T00:00:00Z', k: 'proc', p: 'C:\\y.exe', s: 'made-up-session' }),
70
270
  'not json',
71
- JSON.stringify({ t: '2026-01-05T09:00:02Z', k: 'proc', p: 'x'.repeat(5000) }),
271
+ JSON.stringify({ t: iso(3000), k: 'proc', p: 'x'.repeat(5000) }),
72
272
  ].join('\n'));
73
- t.write(path.join(reports, 'not-a-report.txt'), 'ignored');
74
- const collector = new AuditCollector(data, storage, {
75
- enabled: true,
76
- days: 14,
77
- maxMb: 50,
78
- processes: true,
79
- files: false,
80
- projects: [],
81
- });
273
+ t.write(path.join(run.auditDir, 'not-a-report.txt'), 'ignored');
274
+ // A folder with a report's name stops nothing.
275
+ fs.mkdirSync(path.join(run.auditDir, '0-0.jsonl'), { recursive: true });
276
+ const project = t.dir('audit-data', 'app');
277
+ const collector = new AuditCollector(data, storage, settings(), () => [{ name: 'app', host: project }]);
82
278
  collector.flush();
83
- const got = new AuditLog(AuditLog.dirFor(storage, data), { days: 14, maxMb: 50 }).read({ days: 100000 });
84
- t.equal(got.map((e) => [e.k, e.p, e.w]), [
85
- ['proc', 'fake: all clear', undefined],
86
- ['proc', 'C:\\x.exe', undefined],
87
- ], 'sandbox reports can only be programs: other kinds are recorded as such, fields it may not set are dropped, oversized lines are refused');
88
- t.check(!fs.existsSync(path.join(reports, '1-1.jsonl')), 'reports are deleted once taken in');
279
+ const got = new AuditLog(AuditLog.dirFor(storage, data), { days: 7, maxMb: 50 }).read({ days: 7 });
280
+ const procs = got.filter((e) => e.k === 'proc');
281
+ t.equal(procs.map((e) => [e.p, e.w, e.s, e.ag, e.pj, e.sv]), [
282
+ ['fake: all clear', undefined, 'known-1', 'claude', 'app', 'p'],
283
+ ['C:\\x.exe', undefined, 'known-1', 'claude', 'app', 'p'],
284
+ ['C:\\y.exe', undefined, undefined, undefined, undefined, undefined],
285
+ ], "sandbox reports can only be programs: other kinds are recorded as such, fields it may not set are dropped, the agent comes from this PC's record, a session this sandbox does not have is dropped, oversized lines are refused");
286
+ t.check(procs[1].c === 'x --token ***' && Math.abs(Date.parse(procs[2].t) - Date.now()) < 60000, 'a reported command line loses its secrets, and a time far from now is taken as now', JSON.stringify(procs));
287
+ t.check(got.some((e) => e.k === 'session' && e.a === 'start' && e.s === 'known-1' && e.ag === 'claude'), 'a session starting is recorded with its id and agent');
288
+ t.check(!fs.existsSync(path.join(run.auditDir, '1-1.jsonl')), 'reports are deleted once taken in');
289
+ // A commit in the project: held until the git command that made it could be reported, then linked to it.
290
+ fs.mkdirSync(path.join(project, '.git', 'logs'), { recursive: true });
291
+ const projectLog = path.join(project, '.git', 'logs', 'HEAD');
292
+ fs.writeFileSync(projectLog, '');
293
+ const second = new AuditCollector(data, storage, settings(), () => [{ name: 'app', host: project }]);
294
+ second.start();
295
+ fs.appendFileSync(projectLog, reflogLine(SHA('9'), 'commit: use the token from --password hunter2x'));
296
+ t.write(path.join(run.auditDir, '2-1.jsonl'), JSON.stringify({ t: iso(2000), k: 'proc', p: 'C:\\git\\cmd\\git.exe', c: 'git commit -m x', s: 'known-1' }));
297
+ second.flush(true);
298
+ const commit = new AuditLog(AuditLog.dirFor(storage, data), { days: 7, maxMb: 50 })
299
+ .read({ days: 7, kinds: ['commit'] })
300
+ .at(-1);
301
+ t.check(commit !== undefined &&
302
+ commit.h === SHA('9') &&
303
+ commit.s === 'known-1' &&
304
+ commit.sv === 'p' &&
305
+ commit.ag === 'claude' &&
306
+ commit.au === 'Ada Lovelace <ada@example.com>' &&
307
+ !commit.p.includes('hunter2x'), 'a commit is recorded with its id, author and message (secrets hidden), linked to the session whose git command made it', JSON.stringify(commit));
308
+ t.section('the Activity page');
309
+ const pageLog = new AuditLog(t.at('audit-page'), { days: 7, maxMb: 100 });
310
+ pageLog.append([
311
+ { t: iso(9000), k: 'session', a: 'start', p: 'claude', s: 'sess-claude-1', ag: 'claude', pj: 'app' },
312
+ { t: iso(8000), k: 'proc', p: 'C:\\git.exe', c: 'git status', s: 'sess-claude-1', ag: 'claude', pj: 'app' },
313
+ {
314
+ t: iso(7000),
315
+ k: 'file',
316
+ a: 'change',
317
+ p: 'src/a.ts',
318
+ w: 'F:\\app',
319
+ pj: 'app',
320
+ s: 'sess-claude-1',
321
+ ag: 'claude',
322
+ sv: 't',
323
+ },
324
+ {
325
+ t: iso(6000),
326
+ k: 'commit',
327
+ a: 'commit',
328
+ p: 'add a',
329
+ h: SHA('a'),
330
+ pj: 'app',
331
+ s: 'sess-claude-1',
332
+ ag: 'claude',
333
+ sv: 'p',
334
+ },
335
+ { t: iso(5000), k: 'proc', p: 'C:\\npm.cmd', c: 'npm test', s: 'sess-codex-22', ag: 'codex', pj: 'web', n: 3 },
336
+ { t: iso(4000), k: 'proc', p: 'C:\\setup.exe', c: 'setup /quiet' },
337
+ { t: iso(3000), k: 'guard', p: 'hook: a pre-commit hook was neutralised' },
338
+ ]);
339
+ const listing = (input = {}) => auditListing(pageLog, { enabled: true, days: 7, maxMb: 100 }, listAudit.input.parse(input), 'C:\\audit');
340
+ const all = listing();
341
+ t.equal([all.totals, all.unlinked, all.sessions.map((s) => [s.id, s.agent, s.commands, s.files, s.commits])], [
342
+ { events: 7, commands: 5, files: 1, commits: 1, sessions: 2 },
343
+ { commands: 1, files: 0, commits: 0 },
344
+ [
345
+ ['sess-codex-22', 'codex', 3, 0, 0],
346
+ ['sess-claude-1', 'claude', 1, 1, 1],
347
+ ],
348
+ ], 'the totals, what no session can be linked to, and each session summed up');
349
+ t.equal([
350
+ listing({ session: 'sess-claude' }).events.length,
351
+ listing({ session: NO_SESSION }).events.map((e) => e.kind),
352
+ listing({ agent: 'CODEX' }).events.map((e) => e.what),
353
+ listing({ kind: 'commit' }).events.map((e) => e.sha),
354
+ listing({ grep: 'npm' }).matched,
355
+ listing({ limit: 2 }).events.map((e) => e.kind),
356
+ listing({ project: 'web' }).totals.commands,
357
+ ], [4, ['guard', 'proc'], ['C:\\npm.cmd'], [SHA('a')], 1, ['guard', 'proc'], 3], 'filters: a session (or the start of its id, or none), an agent, a kind, a text, a limit (the newest), a project');
358
+ const page = auditPage(all);
359
+ const sessionsSection = page.sections.find((s) => s.id === 'sessions');
360
+ const card = sessionsSection?.items?.find((i) => i.id === 'session:sess-claude-1');
361
+ t.check(page.sections.map((s) => s.id).join(',') === 'summary,agents,sessions,events' &&
362
+ sessionsSection?.cards === true &&
363
+ card?.primary?.kind === 'link' &&
364
+ card.primary.target.kind === 'view' &&
365
+ card.primary.target.params?.session === 'sess-claude-1' &&
366
+ sessionsSection.items.some((i) => i.id === 'session:' + NO_SESSION) &&
367
+ page.sections.find((s) => s.id === 'summary').facts.some((f) => f.label === 'Commits' && f.value === '1'), 'the page: totals, per agent, each session as a card that opens its own events, then the events', JSON.stringify(page.sections.map((s) => [s.id, s.items?.length, s.facts?.length])));
368
+ const lines = pageLines(page);
369
+ t.check(lines.some((l) => /^Commits\s+1$/.test(l)) &&
370
+ lines.some((l) => /^\s*SESSION\s+AGENT\s+PROJECT\s+STARTED\s+COMMANDS\s+FILES\s+COMMITS$/.test(l)) &&
371
+ lines.some((l) => /^\s*TIME\s+AGENT\s+KIND\s+WHAT$/.test(l)) &&
372
+ lines.some((l) => /claude\?\s+file\s+changed src\/a\.ts/.test(l)) &&
373
+ lines.some((l) => /codex\s+command\s+npm test {2}×3/.test(l)) &&
374
+ lines.some((l) => l.startsWith('? = the only session')), 'on the command line: the totals, a table of sessions and one of events ("?" marks a likely link)', lines.join('\n'));
375
+ const one = auditPage(listing({ session: 'sess-claude-1' }));
376
+ t.check(one.sections[0].id === 'filter' &&
377
+ one.sections[0].summary.includes('session sess-cla') &&
378
+ !one.sections.some((s) => s.id === 'sessions'), "one session's page says so and offers to show everything instead of the session cards");
379
+ t.equal(pageLines(auditPage({ ...listing({ grep: 'nothing-like-this' }), enabled: false })).at(-1), 'The audit log is off. Turn it on in the menu (Settings > Security & privacy > Audit log) or with: sandbox config set audit.enabled true', 'an empty page says how to turn the log on');
380
+ const off = auditPage({
381
+ ...listing({ grep: 'nothing-like-this' }),
382
+ filter: { days: 1, kind: 'all' },
383
+ enabled: false,
384
+ });
385
+ t.check(off.empty?.action?.kind === 'operation' &&
386
+ off.empty.action.op === 'settings.set' &&
387
+ off.empty.action.input.key === 'audit.enabled', 'with the log off, the empty page offers to turn it on (the settings operation, on every surface)');
388
+ t.equal([auditArgv({ days: '7', kind: 'all', session: 'abc' }), auditArgv({ kind: 'commit' })], [
389
+ ['audit', '--days', '7', '--session', 'abc'],
390
+ ['audit', '--kind', 'commit'],
391
+ ], "the menu shows a session's events with the same command line");
392
+ const menu = menuView(page, { category: 'activity', prefix: 'audit:' });
393
+ const chosen = menu.resolve({ kind: 'pick', value: 'audit:session:sess-claude-1' });
394
+ t.check(chosen?.kind === 'action' && chosen.action.kind === 'link' && chosen.action.target.kind === 'view', "in the menu, Enter on a session's row opens its events");
395
+ t.equal(auditView.inputs({ days: '14', kind: 'bogus', session: 'x' }), { audit: { days: 14, kind: 'all', session: 'x' } }, "the web page's address becomes the query's input (values it does not know are left at their default)");
89
396
  },
90
397
  };
@@ -570,6 +570,41 @@ export const hcsSuite = {
570
570
  t.check(refused(['grant-vm-access', 'agent-sandbox-1', notDisk]) &&
571
571
  refused(['grant-vm-access', 'agent-sandbox-1', missing]) &&
572
572
  refused(['grant-vm-access', 'agent-sandbox-1', 'relative.vhdx']), 'grant-vm-access refuses anything but an existing .vhdx at a full path');
573
+ // share (prototype, docs/design/hcs-live-folders.md): refused before HCS is asked, so no Hyper-V is needed.
574
+ const folder = t.dir('hcs-share-folder');
575
+ const share = (over) => ({
576
+ id: 'agent-sandbox-1',
577
+ action: 'add',
578
+ name: 'agentsbx-0123abcd',
579
+ path: folder,
580
+ ...over,
581
+ });
582
+ let link = '';
583
+ try {
584
+ link = path.join(tempRoot, 'share-link');
585
+ fs.symlinkSync(folder, link, 'junction');
586
+ }
587
+ catch {
588
+ link = ''; // a folder that refuses links (a mapped folder): that case is skipped
589
+ }
590
+ t.check(refused(['share'], share({ id: '0b9a3c2e-1f00-4a4c-9d1e-6a7b8c9d0e1f' })) &&
591
+ refused(['share'], share({ id: 'WindowsSandbox' })) &&
592
+ refused(['share'], share({ name: '3ad483dec1a233132da4197c9790dcda06668793230cd6d9e94b1882e0f7f29c' })) &&
593
+ refused(['share'], share({ name: 'agentsbx-XYZ' })) &&
594
+ refused(['share'], share({ action: 'update' })) &&
595
+ refused(['share'], share({ path: 'relative\\folder' })) &&
596
+ refused(['share'], share({ path: 'C:\\' })) &&
597
+ refused(['share'], share({ path: path.join(tempRoot, 'missing-folder') })) &&
598
+ refused(['share'], share({ path: folder + '\\..\\' + path.basename(folder) })) &&
599
+ refused(['share'], share({ path: folder + '/../' + path.basename(folder) })) &&
600
+ refused(['share'], share({ path: folder + ':stream' })) &&
601
+ refused(['share'], share({ path: folder + '.' })) &&
602
+ refused(['share'], share({ path: folder + ' ' })) &&
603
+ refused(['share'], share({ path: 'C:\\PROGRA~1' })) &&
604
+ refused(['share'], share({ readOnly: 'yes' })) &&
605
+ (!link || refused(['share'], share({ path: link }))), 'share refuses a Windows Sandbox or other foreign id, a foreign share name, an update, a drive, a relative, ' +
606
+ "missing, dotted, '/', stream, trailing-dot or -space, short-name or linked folder" +
607
+ (link ? '' : ' (link case skipped)'));
573
608
  const existing = t.write(path.join(tempRoot, 'existing.vhdx'), 'keep me');
574
609
  const over = raw.call(['create-vhdx', existing, '1']);
575
610
  t.check(!over.ok && fs.readFileSync(existing, 'utf8') === 'keep me', 'create-vhdx never touches an existing file', over.message);
@@ -329,7 +329,7 @@ export function renderScreens(dir) {
329
329
  web: { running: true, port: 47800, address: 'http://127.0.0.1:47800/' },
330
330
  more: [
331
331
  { name: 'sync', summary: 'sync history now' },
332
- { name: 'audit', summary: 'show what the sandbox did' },
332
+ { name: 'config', summary: 'your settings file' },
333
333
  ],
334
334
  };
335
335
  // Two unsaved changes in Settings.
@@ -624,12 +624,12 @@ export const menuSuite = {
624
624
  t.section('apps in a few groups');
625
625
  const groupIds = appsCategories(user2).map((c) => c.id);
626
626
  const appItems = appsItems(user2, t.dir('apps-groups'), new Draft()).filter((i) => i.value !== REQUEST_APP);
627
- t.check(groupIds.length <= 6 &&
628
- appsCategories(ConfigFile.project(t.dir('apps-groups', 'project'))).length <= 5 &&
627
+ t.check(groupIds.length <= 7 &&
628
+ appsCategories(ConfigFile.project(t.dir('apps-groups', 'project'))).length <= 6 &&
629
629
  APP_GROUPS.every((g) => appItems.some((i) => i.category === g.id)) &&
630
630
  appItems.every((i) => groupIds.includes(i.category ?? '')) &&
631
631
  appItems.length === appRows(user2, t.dir('apps-groups')).length &&
632
- APP_CATEGORIES.every((c) => APP_GROUPS.some((g) => g.members.includes(c.id))), 'the apps are in at most 6 groups; every catalogue category belongs to one and every app is listed', groupIds.join());
632
+ APP_CATEGORIES.every((c) => APP_GROUPS.some((g) => g.members.includes(c.id))), 'the apps are in at most 7 groups (with your own programs); every catalogue category belongs to one and every app is listed', groupIds.join());
633
633
  t.section('dialog box');
634
634
  const box = dialogBox({ title: 'Unsaved changes', lines: ['You have 2 unsaved changes.'], buttons: 'Save' }, 61);
635
635
  const base = Array.from({ length: 12 }, () => 'x'.repeat(60));
@@ -30,6 +30,15 @@ export const LAUNCH_FILES_GOLDEN = [
30
30
  'runtime/main.js',
31
31
  'runtime/netproxy.js',
32
32
  'runtime/view.js',
33
+ // The agent add-ons' lock files (pinned installs), copied with the rest of sandbox\.
34
+ 'sandbox/addons/ccusage/package-lock.json',
35
+ 'sandbox/addons/ccusage/package.json',
36
+ 'sandbox/addons/context7/package-lock.json',
37
+ 'sandbox/addons/context7/package.json',
38
+ 'sandbox/addons/headroom/requirements.in',
39
+ 'sandbox/addons/headroom/requirements.txt',
40
+ 'sandbox/addons/playwright-mcp/package-lock.json',
41
+ 'sandbox/addons/playwright-mcp/package.json',
33
42
  'sandbox/boot/bootstrap.ps1',
34
43
  'sandbox/boot/bootstrap/drives.ps1',
35
44
  'sandbox/boot/bootstrap/files.ps1',
@@ -45,6 +45,7 @@ export declare function createFixtures(t: Harness): {
45
45
  maxMb: number;
46
46
  processes: boolean;
47
47
  files: boolean;
48
+ commits: boolean;
48
49
  };
49
50
  mappings: {
50
51
  host: string;