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.
- package/README.md +12 -6
- package/config.example.json +4 -3
- package/dist/app/features/apps/apps-rows.d.ts +4 -1
- package/dist/app/features/apps/apps-rows.js +7 -2
- package/dist/app/features/apps/apps.def.d.ts +19 -0
- package/dist/app/features/apps/apps.def.js +49 -12
- package/dist/app/features/apps/apps.run.d.ts +2 -0
- package/dist/app/features/apps/apps.run.js +1 -1
- package/dist/app/features/audit/audit.def.d.ts +341 -0
- package/dist/app/features/audit/audit.def.js +454 -0
- package/dist/app/features/audit/audit.run.d.ts +107 -0
- package/dist/app/features/audit/audit.run.js +133 -0
- package/dist/app/features/audit/index.d.ts +2 -0
- package/dist/app/features/audit/index.js +3 -0
- package/dist/app/features/defs.d.ts +194 -1
- package/dist/app/features/defs.js +4 -0
- package/dist/app/features/settings/settings.run.d.ts +3 -0
- package/dist/app/features/settings/settings.run.js +16 -2
- package/dist/app/kernel/define.d.ts +12 -1
- package/dist/app/registry.js +2 -0
- package/dist/cli/args.d.ts +0 -3
- package/dist/cli/args.js +3 -4
- package/dist/cli/commands/index.d.ts +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/launch/prepare.js +1 -1
- package/dist/cli/commands/maintenance/index.d.ts +0 -1
- package/dist/cli/commands/maintenance/index.js +0 -1
- package/dist/cli/commands/registry.js +1 -2
- package/dist/cli/manifest.d.ts +0 -41
- package/dist/cli/manifest.js +0 -14
- package/dist/cli/menu/pages/apps-page.js +2 -15
- package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
- package/dist/cli/menu/pages/maintenance-page.js +51 -8
- package/dist/cli/menu/views.js +2 -0
- package/dist/core/config/defaults.js +1 -1
- package/dist/core/schema/apps.d.ts +9 -0
- package/dist/core/schema/apps.js +9 -0
- package/dist/core/schema/config.d.ts +1 -0
- package/dist/core/schema/config.js +3 -3
- package/dist/core/schema/sandbox.d.ts +1 -0
- package/dist/core/schema/sandbox.js +11 -3
- package/dist/guest/apps/app-installer.js +50 -4
- package/dist/guest/apps/index.d.ts +1 -1
- package/dist/guest/apps/index.js +1 -1
- package/dist/guest/apps/sources.js +1 -1
- package/dist/guest/audit/parse.d.ts +32 -2
- package/dist/guest/audit/parse.js +43 -5
- package/dist/guest/audit/process-audit.d.ts +3 -1
- package/dist/guest/audit/process-audit.js +9 -6
- package/dist/guest/boot/boot.js +12 -3
- package/dist/guest/boot/guide.d.ts +1 -1
- package/dist/guest/boot/guide.js +27 -3
- package/dist/guest/daemon/sessions.d.ts +7 -0
- package/dist/guest/daemon/sessions.js +27 -2
- package/dist/guest/entries/daemon.js +2 -1
- package/dist/guest/setup/addon-mcp.d.ts +2 -0
- package/dist/guest/setup/addon-mcp.js +49 -0
- package/dist/guest/setup/agent-addons.d.ts +9 -0
- package/dist/guest/setup/agent-addons.js +22 -0
- package/dist/guest/setup/headroom.d.ts +7 -0
- package/dist/guest/setup/headroom.js +60 -0
- package/dist/guest/setup/managed-policy.d.ts +2 -1
- package/dist/guest/setup/managed-policy.js +15 -6
- package/dist/guest/toolchains/plan.js +8 -3
- package/dist/host/audit/attribute.d.ts +46 -0
- package/dist/host/audit/attribute.js +76 -0
- package/dist/host/audit/collect.d.ts +29 -3
- package/dist/host/audit/collect.js +256 -61
- package/dist/host/audit/git-commits.d.ts +29 -0
- package/dist/host/audit/git-commits.js +157 -0
- package/dist/host/audit/index.d.ts +5 -0
- package/dist/host/audit/index.js +6 -0
- package/dist/host/audit/log.d.ts +44 -7
- package/dist/host/audit/log.js +148 -45
- package/dist/host/audit/redact.d.ts +2 -0
- package/dist/host/audit/redact.js +67 -0
- package/dist/runtime/agent.js +110 -15
- package/dist/runtime/daemon.js +569 -316
- package/dist/runtime/db.js +110 -15
- package/dist/runtime/main.js +1011 -672
- package/dist/runtime/netproxy.js +110 -15
- package/dist/runtime/view.js +110 -15
- package/dist/selftest/app.test.js +25 -1
- package/dist/selftest/audit.test.js +354 -47
- package/dist/selftest/hcs.test.js +35 -0
- package/dist/selftest/menu-screens.js +1 -1
- package/dist/selftest/menu.test.js +3 -3
- package/dist/selftest/support/engine-golden.js +9 -0
- package/dist/selftest/support/fixtures.d.ts +1 -0
- package/dist/selftest/support/web-preview.js +94 -0
- package/dist/selftest/toolchains.test.js +147 -2
- package/dist/shared/apps/agent-addons.d.ts +64 -0
- package/dist/shared/apps/agent-addons.js +95 -0
- package/dist/shared/apps/catalogue.d.ts +33 -2
- package/dist/shared/apps/catalogue.js +102 -11
- package/dist/shared/apps/index.d.ts +3 -2
- package/dist/shared/apps/index.js +3 -2
- package/dist/shared/apps/selection.d.ts +2 -0
- package/dist/shared/apps/selection.js +7 -0
- package/dist/sync/watcher.js +9 -1
- package/dist/web/index.html +2 -2
- package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
- package/dist/web/{main-K2JMCAZI.js → main-UD4QCXZR.js} +66 -66
- package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
- package/native/csharp/HcsHost/Hcs.cs +9 -0
- package/native/csharp/HcsHost/Program.cs +26 -1
- package/native/csharp/HcsHost/Share.cs +173 -0
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/package.json +1 -1
- package/sandbox/addons/ccusage/package-lock.json +111 -0
- package/sandbox/addons/ccusage/package.json +7 -0
- package/sandbox/addons/context7/package-lock.json +1313 -0
- package/sandbox/addons/context7/package.json +7 -0
- package/sandbox/addons/headroom/requirements.in +1 -0
- package/sandbox/addons/headroom/requirements.txt +2922 -0
- package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
- package/sandbox/addons/playwright-mcp/package.json +7 -0
- package/schemas/config.schema.json +13 -4
- package/schemas/sandbox-config.schema.json +2 -2
- package/dist/cli/commands/maintenance/audit.d.ts +0 -3
- 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
|
-
|
|
33
|
+
private maintainedOn;
|
|
34
|
+
constructor(dataPath: string, storageRoot: string, settings: AuditSettings, projects?: () => readonly AuditProject[]);
|
|
22
35
|
private add;
|
|
23
|
-
/**
|
|
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"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
[
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 =
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
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,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';
|