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
@@ -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';
@@ -1,11 +1,19 @@
1
1
  import { z } from 'zod';
2
- /** One event. k: proc (a program started), file (changed in a project), session, guard (repo guard), note. */
2
+ export declare const AUDIT_KINDS: readonly ["proc", "file", "commit", "session", "guard", "note"];
3
+ export type AuditKind = (typeof AUDIT_KINDS)[number];
4
+ /** A session id as the sandbox's run folder names them. */
5
+ export declare const AuditSessionIdSchema: z.ZodString;
6
+ /**
7
+ * One event. k: proc (a program started), file (changed in a project), commit (a git commit in a project), session,
8
+ * guard (repo guard), note.
9
+ */
3
10
  export declare const AuditEventSchema: z.ZodObject<{
4
11
  t: z.ZodString;
5
12
  k: z.ZodEnum<{
6
13
  file: "file";
7
14
  session: "session";
8
15
  note: "note";
16
+ commit: "commit";
9
17
  proc: "proc";
10
18
  guard: "guard";
11
19
  }>;
@@ -15,8 +23,23 @@ export declare const AuditEventSchema: z.ZodObject<{
15
23
  a: z.ZodOptional<z.ZodString>;
16
24
  w: z.ZodOptional<z.ZodString>;
17
25
  n: z.ZodOptional<z.ZodNumber>;
26
+ s: z.ZodOptional<z.ZodString>;
27
+ ag: z.ZodOptional<z.ZodString>;
28
+ pj: z.ZodOptional<z.ZodString>;
29
+ sv: z.ZodOptional<z.ZodEnum<{
30
+ p: "p";
31
+ t: "t";
32
+ }>>;
33
+ h: z.ZodOptional<z.ZodString>;
34
+ au: z.ZodOptional<z.ZodString>;
18
35
  }, z.core.$strip>;
19
36
  export type AuditEvent = z.output<typeof AuditEventSchema>;
37
+ /** An event with every text field cut to its limit. */
38
+ export declare function clipEvent(e: AuditEvent): AuditEvent;
39
+ /** The audit log keeps at most this many days, whatever audit.days says (older settings allowed up to 365). */
40
+ export declare const MAX_AUDIT_DAYS = 30;
41
+ /** The first day (YYYY-MM-DD, UTC like the day files) of a window of `days` days ending today. */
42
+ export declare function firstDay(days: number, now?: number): string;
20
43
  export declare class AuditLog {
21
44
  readonly dir: string;
22
45
  private readonly limits;
@@ -27,20 +50,34 @@ export declare class AuditLog {
27
50
  /** <storage>\audit\<data set name>. */
28
51
  static dirFor(storageRoot: string, dataPath: string): string;
29
52
  private dayFile;
30
- append(events: AuditEvent[]): void;
53
+ private dayFiles;
54
+ /**
55
+ * Writes events (each cut to its fields' limits and checked, so every line written can be read back). Past
56
+ * audit.maxMb nothing more is written but one note saying so, until old days are deleted.
57
+ */
58
+ append(events: readonly AuditEvent[]): void;
59
+ private fullNoted;
60
+ /** One line saying the log is full (once until it has room again). */
61
+ private noteFull;
31
62
  /** Compresses finished days, then deletes days past the limits. Cheap when there is nothing to do. */
32
63
  maintain(today?: string): void;
33
- /** Events of the last `days` days (newest last), optionally of some kinds and containing a text. */
64
+ /** The text of one day's file, or '' when it cannot be read within the limits. */
65
+ private dayText;
66
+ /**
67
+ * Events of the last `days` days (never older than audit.days), oldest first, optionally of some kinds and
68
+ * containing a text. At most MAX_READ_EVENTS (the newest).
69
+ */
34
70
  read(o?: {
35
71
  days?: number;
36
- kinds?: AuditEvent['k'][];
72
+ kinds?: readonly AuditKind[];
37
73
  grep?: string;
38
74
  }): AuditEvent[];
39
75
  /** Total size on disk. */
40
76
  bytes(): number;
41
77
  }
42
78
  /**
43
- * Folds repeats: the same event (kind, program/path, command line, action, project) within one minute becomes one line
44
- * with n = how many times; after `cap` distinct lines a minute, the rest of that minute is one "note" line.
79
+ * Folds repeats: the same event (kind, program/path, command line, action, project, session) within one minute becomes
80
+ * one line with n = how many times; after `cap` distinct lines a minute, the rest of that minute is one "note" line.
81
+ * Commits are never folded or dropped.
45
82
  */
46
- export declare function fold(events: AuditEvent[], cap?: number): AuditEvent[];
83
+ export declare function fold(events: readonly AuditEvent[], cap?: number): AuditEvent[];
@@ -1,29 +1,77 @@
1
1
  // The audit log (audit.enabled): one file per day of JSON lines in host-only storage (<storage>\audit\<data set>),
2
2
  // which the sandbox cannot reach. Days before today are gzipped; days past audit.days and, past audit.maxMb, the
3
- // oldest days are deleted. Lines are short (field names are one or two letters) and capped in length.
3
+ // oldest days are deleted. Lines are short (field names are one or two letters) and capped in length; reading back is
4
+ // bounded too (a day's file, the events read).
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import zlib from 'node:zlib';
7
8
  import { z } from 'zod';
8
- /** One event. k: proc (a program started), file (changed in a project), session, guard (repo guard), note. */
9
+ export const AUDIT_KINDS = ['proc', 'file', 'commit', 'session', 'guard', 'note'];
10
+ /** A session id as the sandbox's run folder names them. */
11
+ export const AuditSessionIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
12
+ /**
13
+ * One event. k: proc (a program started), file (changed in a project), commit (a git commit in a project), session,
14
+ * guard (repo guard), note.
15
+ */
9
16
  export const AuditEventSchema = z.object({
10
17
  /** ISO time. */
11
18
  t: z.string().max(40),
12
- k: z.enum(['proc', 'file', 'session', 'guard', 'note']),
13
- /** proc: program; file: path in the project; session: agent; guard/note: what happened. */
19
+ k: z.enum(AUDIT_KINDS),
20
+ /** proc: program; file: path in the project; commit: its message (first line); session: agent; guard/note: what. */
14
21
  p: z.string().max(1024).optional(),
15
- /** proc: its command line (capped). */
22
+ /** proc: its command line (capped, secrets hidden). */
16
23
  c: z.string().max(2048).optional(),
17
24
  /** proc: the program that started it. */
18
25
  pp: z.string().max(1024).optional(),
19
- /** file: change | rename; session: start | end. */
26
+ /** file: change | rename; session: start | end; commit: commit | amend | merge | cherry-pick | revert | rebase. */
20
27
  a: z.string().max(32).optional(),
21
- /** project folder (file, session). */
28
+ /** project folder on this PC (file, commit, session). */
22
29
  w: z.string().max(1024).optional(),
23
30
  /** how many times it happened in this line's minute (repeats are counted, not repeated). */
24
31
  n: z.number().int().min(1).max(1e9).optional(),
32
+ /** The agent session it belongs to, when known. */
33
+ s: AuditSessionIdSchema.optional(),
34
+ /** That session's agent and project (kept here: the session's own record goes with the sandbox). */
35
+ ag: z.string().max(64).optional(),
36
+ pj: z.string().max(128).optional(),
37
+ /**
38
+ * How it was linked to the session: p = by the programs the session started (as the sandbox reported them); t = it
39
+ * was the only session running in that project at the time (likely, not proven).
40
+ */
41
+ sv: z.enum(['p', 't']).optional(),
42
+ /** commit: its id. */
43
+ h: z
44
+ .string()
45
+ .regex(/^[0-9a-f]{7,64}$/)
46
+ .optional(),
47
+ /** commit: who git says made it ("Name <email>"). */
48
+ au: z.string().max(256).optional(),
25
49
  });
50
+ /** Each text field's limit (the schema's): text is cut to it before writing, so a line is never refused on reading. */
51
+ const FIELD_MAX = { p: 1024, c: 2048, pp: 1024, a: 32, w: 1024, ag: 64, pj: 128, au: 256 };
52
+ /** An event with every text field cut to its limit. */
53
+ export function clipEvent(e) {
54
+ const out = { ...e };
55
+ for (const [key, max] of Object.entries(FIELD_MAX)) {
56
+ const v = out[key];
57
+ if (typeof v === 'string' && v.length > max)
58
+ out[key] = v.slice(0, max - 1) + '…';
59
+ }
60
+ return out;
61
+ }
26
62
  const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.jsonl(\.gz)?$/;
63
+ const DAY_MS = 86400000;
64
+ /** A day's file is never unpacked past this (a damaged or planted file cannot take all memory). */
65
+ const MAX_DAY_BYTES = 128 * 1024 * 1024;
66
+ /** Most events one read returns (the newest ones). */
67
+ const MAX_READ_EVENTS = 400000;
68
+ /** The audit log keeps at most this many days, whatever audit.days says (older settings allowed up to 365). */
69
+ export const MAX_AUDIT_DAYS = 30;
70
+ /** The first day (YYYY-MM-DD, UTC like the day files) of a window of `days` days ending today. */
71
+ export function firstDay(days, now = Date.now()) {
72
+ const kept = Math.min(MAX_AUDIT_DAYS, Math.max(1, days));
73
+ return new Date(now - (kept - 1) * DAY_MS).toISOString().slice(0, 10);
74
+ }
27
75
  export class AuditLog {
28
76
  dir;
29
77
  limits;
@@ -38,19 +86,55 @@ export class AuditLog {
38
86
  dayFile(day, gz = false) {
39
87
  return path.join(this.dir, day + '.jsonl' + (gz ? '.gz' : ''));
40
88
  }
89
+ dayFiles() {
90
+ if (!fs.existsSync(this.dir))
91
+ return [];
92
+ return fs
93
+ .readdirSync(this.dir)
94
+ .filter((n) => DAY_FILE.test(n))
95
+ .sort();
96
+ }
97
+ /**
98
+ * Writes events (each cut to its fields' limits and checked, so every line written can be read back). Past
99
+ * audit.maxMb nothing more is written but one note saying so, until old days are deleted.
100
+ */
41
101
  append(events) {
42
102
  if (!events.length)
43
103
  return;
44
104
  fs.mkdirSync(this.dir, { recursive: true });
105
+ const limit = this.limits.maxMb * 1024 * 1024;
106
+ let size = this.bytes();
107
+ if (size >= limit) {
108
+ this.noteFull(events[events.length - 1].t);
109
+ return;
110
+ }
45
111
  const byDay = new Map();
46
112
  for (const e of events) {
47
113
  const day = e.t.slice(0, 10);
48
- if (!/^\d{4}-\d{2}-\d{2}$/.test(day))
114
+ const checked = AuditEventSchema.safeParse(clipEvent(e));
115
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !checked.success)
49
116
  continue;
50
- byDay.set(day, [...(byDay.get(day) ?? []), JSON.stringify(e)]);
117
+ const line = JSON.stringify(checked.data);
118
+ size += line.length + 1;
119
+ if (size > limit) {
120
+ this.noteFull(e.t);
121
+ break;
122
+ }
123
+ byDay.set(day, [...(byDay.get(day) ?? []), line]);
51
124
  }
52
125
  for (const [day, lines] of byDay)
53
126
  fs.appendFileSync(this.dayFile(day), lines.join('\n') + '\n');
127
+ if (size <= limit)
128
+ this.fullNoted = false;
129
+ }
130
+ fullNoted = false;
131
+ /** One line saying the log is full (once until it has room again). */
132
+ noteFull(t) {
133
+ if (this.fullNoted)
134
+ return;
135
+ this.fullNoted = true;
136
+ const note = { t, k: 'note', p: 'the audit log reached audit.maxMb: nothing more is recorded for now' };
137
+ fs.appendFileSync(this.dayFile(t.slice(0, 10)), JSON.stringify(note) + '\n');
54
138
  }
55
139
  /** Compresses finished days, then deletes days past the limits. Cheap when there is nothing to do. */
56
140
  maintain(today = new Date().toISOString().slice(0, 10)) {
@@ -61,41 +145,58 @@ export class AuditLog {
61
145
  if (!m || m[2] || m[1] >= today)
62
146
  continue;
63
147
  const file = path.join(this.dir, name);
64
- fs.writeFileSync(file + '.gz', zlib.gzipSync(fs.readFileSync(file)));
148
+ // Lines that arrived for a day already compressed (late reports) are added as another gzip member: the day's
149
+ // compressed file is never written over.
150
+ fs.appendFileSync(file + '.gz', zlib.gzipSync(fs.readFileSync(file)));
65
151
  fs.rmSync(file);
66
152
  }
67
- const oldest = new Date(Date.parse(today) - (this.limits.days - 1) * 86400000).toISOString().slice(0, 10);
68
- const files = fs
69
- .readdirSync(this.dir)
70
- .filter((n) => DAY_FILE.test(n))
71
- .sort();
72
- for (const name of files.filter((n) => DAY_FILE.exec(n)[1] < oldest))
153
+ const oldest = firstDay(this.limits.days, Date.parse(today));
154
+ for (const name of this.dayFiles().filter((n) => DAY_FILE.exec(n)[1] < oldest))
73
155
  fs.rmSync(path.join(this.dir, name), { force: true });
74
- let left = fs
75
- .readdirSync(this.dir)
76
- .filter((n) => DAY_FILE.test(n))
77
- .sort();
156
+ let left = this.dayFiles();
78
157
  const size = () => left.reduce((sum, n) => sum + fs.statSync(path.join(this.dir, n)).size, 0);
79
158
  while (left.length > 1 && size() > this.limits.maxMb * 1024 * 1024) {
80
159
  fs.rmSync(path.join(this.dir, left[0]), { force: true });
81
160
  left = left.slice(1);
82
161
  }
83
162
  }
84
- /** Events of the last `days` days (newest last), optionally of some kinds and containing a text. */
163
+ /** The text of one day's file, or '' when it cannot be read within the limits. */
164
+ dayText(name) {
165
+ try {
166
+ const file = path.join(this.dir, name);
167
+ const stat = fs.lstatSync(file);
168
+ if (!stat.isFile() || stat.size > MAX_DAY_BYTES)
169
+ return '';
170
+ const raw = fs.readFileSync(file);
171
+ return name.endsWith('.gz')
172
+ ? zlib.gunzipSync(raw, { maxOutputLength: MAX_DAY_BYTES }).toString('utf8')
173
+ : raw.toString('utf8');
174
+ }
175
+ catch {
176
+ // Damaged or too big once unpacked: that day is left out rather than failing the whole read.
177
+ return '';
178
+ }
179
+ }
180
+ /**
181
+ * Events of the last `days` days (never older than audit.days), oldest first, optionally of some kinds and
182
+ * containing a text. At most MAX_READ_EVENTS (the newest).
183
+ */
85
184
  read(o = {}) {
86
- if (!fs.existsSync(this.dir))
87
- return [];
88
- const since = new Date(Date.now() - ((o.days ?? 1) - 1) * 86400000).toISOString().slice(0, 10);
185
+ const since = firstDay(Math.min(o.days ?? 1, this.limits.days));
89
186
  const needle = o.grep?.toLowerCase();
90
- const events = [];
91
- for (const name of fs
92
- .readdirSync(this.dir)
93
- .filter((n) => DAY_FILE.test(n) && DAY_FILE.exec(n)[1] >= since)
94
- .sort()) {
95
- const raw = fs.readFileSync(path.join(this.dir, name));
96
- const text = name.endsWith('.gz') ? zlib.gunzipSync(raw).toString('utf8') : raw.toString('utf8');
97
- for (const line of text.split('\n')) {
98
- if (!line.trim())
187
+ const days = [];
188
+ let count = 0;
189
+ // Newest day first, so the cap keeps the newest events.
190
+ for (const name of this.dayFiles()
191
+ .filter((n) => DAY_FILE.exec(n)[1] >= since)
192
+ .reverse()) {
193
+ if (count >= MAX_READ_EVENTS)
194
+ break;
195
+ const events = [];
196
+ for (const line of this.dayText(name).split('\n')) {
197
+ if (!line.trim() || line.length > 16384)
198
+ continue;
199
+ if (needle && !line.toLowerCase().includes(needle))
99
200
  continue;
100
201
  let parsed;
101
202
  try {
@@ -109,26 +210,24 @@ export class AuditLog {
109
210
  continue;
110
211
  if (o.kinds?.length && !o.kinds.includes(e.data.k))
111
212
  continue;
112
- if (needle && !line.toLowerCase().includes(needle))
113
- continue;
114
213
  events.push(e.data);
115
214
  }
215
+ const room = MAX_READ_EVENTS - count;
216
+ const kept = events.length > room ? events.slice(events.length - room) : events;
217
+ count += kept.length;
218
+ days.push(kept);
116
219
  }
117
- return events;
220
+ return days.reverse().flat();
118
221
  }
119
222
  /** Total size on disk. */
120
223
  bytes() {
121
- if (!fs.existsSync(this.dir))
122
- return 0;
123
- return fs
124
- .readdirSync(this.dir)
125
- .filter((n) => DAY_FILE.test(n))
126
- .reduce((sum, n) => sum + fs.statSync(path.join(this.dir, n)).size, 0);
224
+ return this.dayFiles().reduce((sum, n) => sum + fs.statSync(path.join(this.dir, n)).size, 0);
127
225
  }
128
226
  }
129
227
  /**
130
- * Folds repeats: the same event (kind, program/path, command line, action, project) within one minute becomes one line
131
- * with n = how many times; after `cap` distinct lines a minute, the rest of that minute is one "note" line.
228
+ * Folds repeats: the same event (kind, program/path, command line, action, project, session) within one minute becomes
229
+ * one line with n = how many times; after `cap` distinct lines a minute, the rest of that minute is one "note" line.
230
+ * Commits are never folded or dropped.
132
231
  */
133
232
  export function fold(events, cap = 300) {
134
233
  const out = [];
@@ -136,8 +235,12 @@ export function fold(events, cap = 300) {
136
235
  const perMinute = new Map();
137
236
  const dropped = new Map();
138
237
  for (const e of events) {
238
+ if (e.k === 'commit') {
239
+ out.push({ ...e });
240
+ continue;
241
+ }
139
242
  const minute = e.t.slice(0, 16);
140
- const key = [minute, e.k, e.p, e.c, e.a, e.w].join('\0');
243
+ const key = [minute, e.k, e.p, e.c, e.a, e.w, e.s].join('\0');
141
244
  const seen = index.get(key);
142
245
  if (seen) {
143
246
  seen.n = (seen.n ?? 1) + (e.n ?? 1);
@@ -0,0 +1,2 @@
1
+ /** `text` with every credential in it replaced by ***. */
2
+ export declare function redactCommandLine(text: string): string;
@@ -0,0 +1,67 @@
1
+ // Secrets never reach the audit log: a command line (or a commit message) is scrubbed before it is written. Values that
2
+ // are credentials whatever they are called (tokens, keys, passwords in URLs: secret-patterns.ts), and the value after
3
+ // a name that says it holds one (--password x, --token=x, API_KEY=x, -p<pw> for mysql, Authorization: ...), become ***.
4
+ // The text is already capped (2 KB), so these simple patterns stay cheap.
5
+ import { REDACTED } from '../../core/schema/index.js';
6
+ import { SECRET_VALUES, SECRET_WORDS } from '../profile/secret-patterns.js';
7
+ /** The value patterns, each finding every match; a match runs on to the end of its word (the rest of the secret). */
8
+ const VALUES = SECRET_VALUES.map((r) => new RegExp('(?:' + r.source + ')[^\\s"\'`]*', r.flags.includes('g') ? r.flags : r.flags + 'g'));
9
+ /**
10
+ * `--name value`, `--name=value`, `/name:value`, `NAME=value`, `$env:NAME = "value"`: the flag's dashes (a value after
11
+ * a space only counts for a flag), the name, the separator, and the value (looked at, not taken: the next word may be
12
+ * a name of its own).
13
+ */
14
+ const NAMED = /(?<=^|[\s"'`;&|(])(?:\$env:)?(--?|\/)?([A-Za-z][\w.-]{1,63})(\s*[=:]\s*|\s+)(?=("[^"]*"|'[^']*'|[^\s"'`;&|)]+))/g;
15
+ /** Headers whose whole value is a credential. */
16
+ const HEADERS = /\b(authorization|proxy-authorization|x-api-key|api-key|x-auth-token|cookie|private-token)(\s*:\s*)[^"'\r\n]+/gi;
17
+ /** mysql -p<password> / -pPASSWORD (no space), and user:password for curl (-u x:y, -ux:y, --user=x:y). */
18
+ const SHORT = /(\s-p)(?!\s)([^\s"'`]+)|(\s(?:-u|--user)(?:\s+|=)?)([^\s:"'`]+):([^\s"'`]+)/g;
19
+ /** A token as a URL's user name (https://<token>@host), and PowerShell's ConvertTo-SecureString "<password>". */
20
+ const MORE = [
21
+ /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:"'`]{8,}@/gi,
22
+ /(ConvertTo-SecureString\s+(?:-String\s+)?)("[^"]*"|'[^']*'|[^\s"'`)]+)/gi,
23
+ // `-p <password>` only where it means one (docker/podman/npm login, sshpass): elsewhere -p is a port or a package.
24
+ /(\blogin\b[^&|;\r\n]{0,200}?\s-p\s+|\bsshpass\s+-p\s+)("[^"]*"|'[^']*'|[^\s"'`]+)/gi,
25
+ ];
26
+ /** Words that make any name a secret's wherever they stand in it (PGPASSWORD, accessToken, mySecretKey). */
27
+ const ANYWHERE = /PASSW(OR)?D|TOKEN|SECRET|APIKEY|API_KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL/;
28
+ /** Does a name (a flag or a variable) say it holds a secret? camelCase counts as words. */
29
+ function secretName(name) {
30
+ const upper = name
31
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
32
+ .toUpperCase()
33
+ .replace(/[-.]/g, '_');
34
+ return SECRET_WORDS.test(upper) || ANYWHERE.test(upper);
35
+ }
36
+ /** The values after names that say they hold a secret, as *** (see NAMED). */
37
+ function redactNamed(text) {
38
+ const cuts = [];
39
+ for (const m of text.matchAll(NAMED)) {
40
+ const [whole, dashes, name, sep, value] = m;
41
+ if (!secretName(name) || !(dashes || sep.trim()) || value === REDACTED || value.startsWith('-'))
42
+ continue;
43
+ const start = (m.index ?? 0) + whole.length;
44
+ cuts.push([start, start + value.length]);
45
+ }
46
+ let out = '';
47
+ let from = 0;
48
+ for (const [start, end] of cuts) {
49
+ if (start < from)
50
+ continue;
51
+ out += text.slice(from, start) + REDACTED;
52
+ from = end;
53
+ }
54
+ return out + text.slice(from);
55
+ }
56
+ /** `text` with every credential in it replaced by ***. */
57
+ export function redactCommandLine(text) {
58
+ let out = text;
59
+ for (const r of VALUES)
60
+ out = out.replace(r, REDACTED);
61
+ out = out.replace(HEADERS, (_m, name, sep) => name + sep + REDACTED);
62
+ for (const r of MORE)
63
+ out = out.replace(r, (_m, lead) => lead + REDACTED + (r === MORE[0] ? '@' : ''));
64
+ out = redactNamed(out);
65
+ out = out.replace(SHORT, (m, p, _pw, u, user) => p ? p + REDACTED : u ? u + user + ':' + REDACTED : m);
66
+ return out;
67
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ReleaseInfo } from './release-source.js';
2
2
  import type { UpdateState } from './update-state.js';
3
- export type SkipReason = 'no-release' | 'up-to-date' | 'failed-before' | 'sandbox-running' | 'too-soon';
3
+ export type SkipReason = 'no-release' | 'up-to-date' | 'failed-before' | 'too-soon';
4
4
  export type UpdateDecision = {
5
5
  kind: 'install';
6
6
  release: ReleaseInfo;
@@ -24,6 +24,7 @@ export interface DecisionInput {
24
24
  export declare function isTooSoon(state: UpdateState, now: Date, minHours: number): boolean;
25
25
  /**
26
26
  * Install only a version newer than the current one, and never a version that failed before (or an older one than
27
- * the failure). The sandbox must not be running: its files are in use.
27
+ * the failure). A running sandbox is no reason to wait: each version has its own folder, the sandbox and this PC's
28
+ * helpers keep running the version they started with, and old versions are only removed when no sandbox runs.
28
29
  */
29
30
  export declare function decideUpdate(input: DecisionInput): UpdateDecision;
@@ -12,14 +12,13 @@ export function isTooSoon(state, now, minHours) {
12
12
  }
13
13
  /**
14
14
  * Install only a version newer than the current one, and never a version that failed before (or an older one than
15
- * the failure). The sandbox must not be running: its files are in use.
15
+ * the failure). A running sandbox is no reason to wait: each version has its own folder, the sandbox and this PC's
16
+ * helpers keep running the version they started with, and old versions are only removed when no sandbox runs.
16
17
  */
17
18
  export function decideUpdate(input) {
18
19
  const skip = (reason) => ({ kind: 'skip', reason });
19
20
  if (!input.force && isTooSoon(input.state, input.now, input.minHoursBetweenChecks))
20
21
  return skip('too-soon');
21
- if (input.sandboxRunning)
22
- return skip('sandbox-running');
23
22
  if (!input.latest)
24
23
  return skip('no-release');
25
24
  const latest = input.latest;
@@ -70,7 +70,10 @@ export declare class Updater {
70
70
  private checkEntry;
71
71
  /** Through the shim, as a user runs it: the pointers now name this version. */
72
72
  private checkShim;
73
- /** Records the update, keeps the replaced version for a rollback and removes the rest. */
73
+ /**
74
+ * Records the update, keeps the replaced version for a rollback and removes the rest, but only when no sandbox runs:
75
+ * its helpers on this PC may still run an older version from its folder.
76
+ */
74
77
  private finish;
75
78
  private discard;
76
79
  private recordFailure;