software-defence-factory 0.3.1 → 0.3.3
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/docs/quickstart.md +11 -0
- package/factory/executor.mjs +22 -5
- package/factory/lib.mjs +2 -0
- package/package.json +1 -1
- package/scripts/probe-platform.mjs +28 -0
package/docs/quickstart.md
CHANGED
|
@@ -54,3 +54,14 @@ ssh -N -L 127.0.0.1:7331:127.0.0.1:7331 your-host
|
|
|
54
54
|
Open http://127.0.0.1:7331 on the client. Use the same local/remote port because the HTTP service validates its Host header. The SSH connection must remain open. Access also works across different networks when your configured private network connects the hosts.
|
|
55
55
|
|
|
56
56
|
Use `status`, `cancel JOB_ID`, `retry JOB_ID` and `stop`, always with the selected `--state`. `service` prints a systemd user-service definition; review and install it through the host's normal service management. It is not enabled by printing it. See [recovery](recovery.md).
|
|
57
|
+
|
|
58
|
+
## Native application builds
|
|
59
|
+
|
|
60
|
+
Pin an application-specific image with the required toolchains. In `factory.json`,
|
|
61
|
+
`cpus` (1–32, default 2), `pidsLimit` (64–16384, default 256), `memoryMiB` and
|
|
62
|
+
`timeoutSeconds` bound the job's resources. Verification uses a separate
|
|
63
|
+
disk-backed checkout, keeping the candidate read-only. Its private scratch
|
|
64
|
+
directory is removed after container termination is confirmed. Interrupted
|
|
65
|
+
executors may retain scratch under their attempt for recovery; stop/reconcile
|
|
66
|
+
the job before removing it. Ensure the state filesystem has sufficient space.
|
|
67
|
+
No Docker socket, operator credentials or unrelated project caches are mounted.
|
package/factory/executor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, lstatSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, lstatSync, chmodSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { ROOT, configAt, run, save, json, digest, instanceLabel, stopContainers } from './lib.mjs';
|
|
@@ -44,21 +44,35 @@ function safeRead(path) {
|
|
|
44
44
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error('Expected a regular report under 1 MiB');
|
|
45
45
|
return readFileSync(path, 'utf8');
|
|
46
46
|
}
|
|
47
|
+
// Called only after the container has stopped. Builds may leave read-only cache
|
|
48
|
+
// directories; make owned directories traversable without following symlinks.
|
|
49
|
+
function removeScratch(path) {
|
|
50
|
+
if (lstatSync(path).isDirectory()) {
|
|
51
|
+
chmodSync(path, 0o700);
|
|
52
|
+
for (const name of readdirSync(path)) removeScratch(join(path, name));
|
|
53
|
+
}
|
|
54
|
+
rmSync(path, { recursive: true, force: true });
|
|
55
|
+
}
|
|
47
56
|
async function container(mode, input, command, writable = false, credentials = false) {
|
|
48
57
|
const name = `sdf-${instanceLabel(state)}-${attempt}-${mode}`;
|
|
49
58
|
const reportDir = join(folder, attempt, mode);
|
|
50
59
|
mkdirSync(reportDir, { recursive: true, mode: 0o700 });
|
|
60
|
+
// Native builds need disk-backed scratch space, not the small temporary RAM disk.
|
|
61
|
+
// Only this attempt can write here; the candidate and its Git metadata stay read-only.
|
|
62
|
+
const scratch = mode === 'verify' ? join(folder, attempt, 'check-workspace') : null;
|
|
63
|
+
if (scratch) mkdirSync(scratch, { mode: 0o700 });
|
|
51
64
|
const uid = process.getuid(), gid = process.getgid();
|
|
52
65
|
const args = ['run','--name',name,'--init','--read-only','--cap-drop=ALL','--security-opt=no-new-privileges',
|
|
53
|
-
'--pids-limit
|
|
66
|
+
'--pids-limit',String(config.pidsLimit ?? 256),'--memory',`${config.memoryMiB}m`,'--cpus',String(config.cpus ?? 2), '--user',`${uid}:${gid}`,
|
|
54
67
|
'--network',config.network,'--label',`sdf.factory=${instanceLabel(state)}`,'--label',`sdf.job=${job}`,
|
|
55
68
|
'--label',`sdf.run=${attempt}`,'--label',`sdf.deadline=${Date.now() + config.timeoutSeconds * 1000}`,
|
|
56
|
-
'--tmpfs','/tmp:rw,nosuid,size=1024m','--env'
|
|
69
|
+
'--tmpfs','/tmp:rw,nosuid,size=1024m','--env',`HOME=${scratch ? '/scratch/home' : '/tmp/home'}`,'--env',`FACTORY_PHASE=${mode}`,
|
|
57
70
|
'--mount',`type=bind,source=${workspace},target=/workspace${writable ? '' : ',readonly'}`,
|
|
58
71
|
'--mount',`type=bind,source=${join(workspace,'.git')},target=/workspace/.git,readonly`,
|
|
59
72
|
'--mount',`type=bind,source=${reportDir},target=/output`,
|
|
60
73
|
'--mount',`type=bind,source=${join(ROOT,'kit')},target=/factory-policy,readonly`,
|
|
61
74
|
'--mount',`type=bind,source=${join(ROOT,'.agents/skills')},target=/factory-skills,readonly`];
|
|
75
|
+
if (scratch) args.push('--mount',`type=bind,source=${scratch},target=/scratch`);
|
|
62
76
|
if (credentials) args.push('--env-file', join(state,'model.env'));
|
|
63
77
|
args.push('-i',config.image,'timeout','--signal=KILL',`${config.timeoutSeconds}s`,'sh','-c','mkdir -p "$HOME" && exec "$@"','factory',...command);
|
|
64
78
|
console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: config.agent === 'mock' }));
|
|
@@ -79,7 +93,10 @@ async function container(mode, input, command, writable = false, credentials = f
|
|
|
79
93
|
const probe = run('docker',['ps','-aq','--filter',`name=^/${name}$`]);
|
|
80
94
|
if (probe) throw error;
|
|
81
95
|
}
|
|
82
|
-
|
|
96
|
+
let cleanupError;
|
|
97
|
+
try { if (scratch) removeScratch(scratch); } catch (error) { cleanupError = error; }
|
|
98
|
+
if (code !== 0) throw new Error(`${mode} exited ${code}; private log: ${logPath}${cleanupError ? `; scratch cleanup requires recovery: ${cleanupError.message}` : ''}`);
|
|
99
|
+
if (cleanupError) throw cleanupError;
|
|
83
100
|
return reportDir;
|
|
84
101
|
}
|
|
85
102
|
function brief(instruction) {
|
|
@@ -109,7 +126,7 @@ try {
|
|
|
109
126
|
} else if (phase === 'verify') {
|
|
110
127
|
const meta = candidate();
|
|
111
128
|
if (!config.check?.trim()) throw new Error('Configure an actual app check before software delivery');
|
|
112
|
-
const check = ['sh','-c','mkdir -p /
|
|
129
|
+
const check = ['sh','-c','mkdir -p /scratch/check && cp -R /workspace/. /scratch/check/ && cd /scratch/check && exec sh -c "$1"','check',config.check];
|
|
113
130
|
await container('verify', '', check);
|
|
114
131
|
candidate();
|
|
115
132
|
const proof = { head: meta.head, policyHash, command: config.check, passed: true, finishedAt: new Date().toISOString(), synthetic: meta.synthetic };
|
package/factory/lib.mjs
CHANGED
|
@@ -33,6 +33,8 @@ export function configAt(state) {
|
|
|
33
33
|
if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535) throw new Error('Invalid port');
|
|
34
34
|
if (!Number.isInteger(config.timeoutSeconds) || config.timeoutSeconds < 1 || config.timeoutSeconds > 7200) throw new Error('timeoutSeconds must be 1–7200');
|
|
35
35
|
if (!Number.isInteger(config.memoryMiB) || config.memoryMiB < 256) throw new Error('Invalid memory limit');
|
|
36
|
+
if (config.cpus !== undefined && (!Number.isInteger(config.cpus) || config.cpus < 1 || config.cpus > 32)) throw new Error('cpus must be 1–32');
|
|
37
|
+
if (config.pidsLimit !== undefined && (!Number.isInteger(config.pidsLimit) || config.pidsLimit < 64 || config.pidsLimit > 16384)) throw new Error('pidsLimit must be 64–16384');
|
|
36
38
|
if (!['none','bridge'].includes(config.network)) throw new Error('network must be none or bridge; host networking is not supported');
|
|
37
39
|
if (typeof config.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/.test(config.image)) throw new Error('Invalid container image');
|
|
38
40
|
if (typeof config.repo !== 'string' || !isAbsolute(config.repo) || [config.repo,state,ROOT].some(p=>/[,\n\r]/.test(p))) throw new Error('Expected absolute paths without commas or line breaks');
|
package/package.json
CHANGED
|
@@ -32,6 +32,33 @@ try {
|
|
|
32
32
|
await cli('approve',pass.id);await waitState(pass.id,'succeeded');
|
|
33
33
|
assert.equal(json(join(folder,'accepted.json')).head,candidate.head);record('software: exact revision, isolated checkout, checks, review, approval, handoff');
|
|
34
34
|
|
|
35
|
+
setConfig({cpus:4,pidsLimit:1024,check:"test \"$(cat value.txt)\" = fixed && dd if=/dev/zero of=large-build-fixture bs=1M count=1100 status=none && sleep 2"});
|
|
36
|
+
const native=await work('Synthetic disk-backed build verification');
|
|
37
|
+
const checking=await until(()=>containers(state).find(c=>c.Config.Labels['sdf.job']===native.id&&c.State.Running&&c.Config.Env.includes('FACTORY_PHASE=verify')));
|
|
38
|
+
assert.equal(checking.HostConfig.NanoCpus,4000000000);assert.equal(checking.HostConfig.PidsLimit,1024);
|
|
39
|
+
assert(checking.Mounts.some(m=>m.Destination==='/scratch'&&m.RW));
|
|
40
|
+
assert(checking.Mounts.some(m=>m.Destination==='/workspace'&&!m.RW));
|
|
41
|
+
await waitState(native.id,'awaiting_approval');
|
|
42
|
+
const verification=(await snapshot(native.id)).runs.find(r=>r.command==='verify');
|
|
43
|
+
assert(!existsSync(join(state,'jobs',native.id,verification.id,'check-workspace')));
|
|
44
|
+
assert(!existsSync(join(state,'jobs',native.id,'checkout/large-build-fixture')));
|
|
45
|
+
await cli('approve',native.id);await waitState(native.id,'succeeded');setConfig({});
|
|
46
|
+
record('native build scratch exceeds 1 GiB without changing the candidate; limits and cleanup verified');
|
|
47
|
+
|
|
48
|
+
for (const exitCode of [0, 17]) {
|
|
49
|
+
setConfig({check:`mkdir -p cache/nested && echo cache > cache/nested/value && ln -s /workspace cache/candidate && chmod 000 cache/nested cache && exit ${exitCode}`});
|
|
50
|
+
const readonly=await work(`Synthetic read-only cache cleanup, exit ${exitCode}`);
|
|
51
|
+
await waitState(readonly.id,exitCode===0?'awaiting_approval':'failed');
|
|
52
|
+
const run=(await snapshot(readonly.id)).runs.find(r=>r.command==='verify');
|
|
53
|
+
assert(!existsSync(join(state,'jobs',readonly.id,run.id,'check-workspace')));
|
|
54
|
+
assert.equal(readFileSync(join(state,'jobs',readonly.id,'checkout/value.txt'),'utf8'),'fixed\n');
|
|
55
|
+
assert.equal(readFileSync(join(original.repo,'value.txt'),'utf8'),'broken\n');
|
|
56
|
+
if(exitCode===0){await cli('approve',readonly.id);await waitState(readonly.id,'succeeded');}
|
|
57
|
+
else assert.match(run.error,/verify exited 17/);
|
|
58
|
+
record(`read-only cache cleanup after exit ${exitCode}; candidate symlink not followed and exit preserved`);
|
|
59
|
+
}
|
|
60
|
+
setConfig({});
|
|
61
|
+
|
|
35
62
|
const changed=await work('Synthetic changed revision guard');await waitState(changed.id,'awaiting_approval');
|
|
36
63
|
writeFileSync(join(state,'jobs',changed.id,'checkout/value.txt'),'changed after review\n');
|
|
37
64
|
await cli('approve',changed.id);await waitState(changed.id,'failed');
|
|
@@ -44,6 +71,7 @@ try {
|
|
|
44
71
|
setConfig({check:'exit 17'});
|
|
45
72
|
const fail=await work('Synthetic failing check');await waitState(fail.id,'failed');
|
|
46
73
|
assert.equal((await snapshot(fail.id)).runs.at(-1).command,'verify');
|
|
74
|
+
assert(!existsSync(join(state,'jobs',fail.id,(await snapshot(fail.id)).runs.at(-1).id,'check-workspace')));
|
|
47
75
|
setConfig({});await cli('retry',fail.id);await waitState(fail.id,'awaiting_approval');
|
|
48
76
|
await cli('approve',fail.id);await waitState(fail.id,'succeeded');record('failed app check blocks delivery; controlled retry rechecks same candidate');
|
|
49
77
|
|