software-defence-factory 0.4.7 → 0.5.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/.agents/skills/factory-implement/SKILL.md +9 -0
- package/.agents/skills/factory-review/SKILL.md +12 -0
- package/.agents/skills/factory-spec/SKILL.md +9 -0
- package/.agents/skills/factory-triage/SKILL.md +7 -0
- package/README.md +11 -15
- package/bin/software-defence-factory.mjs +25 -11
- package/docs/concepts.md +50 -0
- package/docs/defence-integration.md +1 -1
- package/docs/interfaces.md +53 -0
- package/docs/npm.md +1 -1
- package/docs/quickstart.md +15 -4
- package/docs/setup.md +8 -5
- package/docs/workflows.md +38 -64
- package/factory/definition.mjs +56 -0
- package/factory/execution-profile.mjs +9 -7
- package/factory/executor.mjs +5 -4
- package/factory/lib.mjs +9 -1
- package/factory/queue.mjs +1 -1
- package/factory/server.mjs +8 -5
- package/factory/terminology.json +12 -0
- package/factory/ui/assets/index-D0_HaZ5J.js +11 -0
- package/factory/ui/assets/index-lXRVcv9y.css +1 -0
- package/factory/ui/index.html +2 -2
- package/factory/updates.mjs +1 -1
- package/factory/workflows.mjs +2 -42
- package/kit/repository.md +17 -2
- package/operator-skills/factory-foundation/SKILL.md +65 -0
- package/package.json +5 -2
- package/scripts/probe-diagnostics.mjs +2 -1
- package/scripts/probe-platform.mjs +2 -1
- package/scripts/probe-review.mjs +2 -1
- package/factory/ui/assets/index-BqY8avus.js +0 -11
- package/factory/ui/assets/index-DxK01fwc.css +0 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ROOT, digest, harnessOf } from './lib.mjs';
|
|
4
|
+
|
|
5
|
+
// Execution order is shared with the queue; presentation cannot invent phases.
|
|
6
|
+
export const WORKFLOWS = Object.freeze({
|
|
7
|
+
software: Object.freeze(['build', 'verify', 'review', 'handoff']),
|
|
8
|
+
defence: Object.freeze(['defence']),
|
|
9
|
+
});
|
|
10
|
+
const phaseInfo = {
|
|
11
|
+
build: { title: 'Implement', owner: 'agent', skills: ['factory-implement'], description: 'Implement the accepted scope in an isolated checkout. Produce a candidate and evidence.' },
|
|
12
|
+
verify: { title: 'Check', owner: 'factory', skills: [], description: 'Run the project check command against the candidate. A failure stops delivery.' },
|
|
13
|
+
review: { title: 'Review', owner: 'agent', skills: ['factory-review', 'factory-security'], description: 'Review the candidate and evidence in a separate agent invocation. Security review applies when required by the accepted scope.' },
|
|
14
|
+
handoff: { title: 'Accept & hand off', owner: 'operator', skills: [], description: 'Wait for operator approval, then confirm the candidate and policy still match the checks and review. Record acceptance; do not push, merge or deploy.' },
|
|
15
|
+
defence: { title: 'Investigate', owner: 'agent', skills: ['factory-security'], description: 'Investigate supplied incident evidence within the accepted scope. Produce a private draft with findings and unknowns. No production access or recovery action is granted.' },
|
|
16
|
+
};
|
|
17
|
+
const skillRoles = {
|
|
18
|
+
triage: 'Prepare incoming work before admission', spec: 'Define scope and acceptance before admission',
|
|
19
|
+
implement: 'Implement an accepted software task', review: 'Independently assess a candidate',
|
|
20
|
+
security: 'Assess security within the authorized scope', evaluate: 'Compare configurations in a separately scoped evaluation',
|
|
21
|
+
};
|
|
22
|
+
export function factoryDefinition(config) {
|
|
23
|
+
const harness = harnessOf(config);
|
|
24
|
+
const skills = Object.entries(skillRoles).map(([role, purpose]) => {
|
|
25
|
+
const id = `factory-${role}`, path = `.agents/skills/${id}/SKILL.md`;
|
|
26
|
+
const content = readFileSync(join(ROOT, path), 'utf8');
|
|
27
|
+
return { id, purpose, path, content, sha256: digest(content) };
|
|
28
|
+
});
|
|
29
|
+
const workflows = Object.fromEntries(Object.entries(WORKFLOWS).map(([name, phases]) => [name, phases.map(name => ({ name, approval: name === 'handoff' }))]));
|
|
30
|
+
return {
|
|
31
|
+
version: 1, workflows, terminology: JSON.parse(readFileSync(join(ROOT, 'factory/terminology.json'), 'utf8')),
|
|
32
|
+
agents: Object.entries(phaseInfo).filter(([, info]) => info.owner === 'agent').map(([phase, info]) => ({
|
|
33
|
+
id: phase === 'build' ? 'implement' : phase === 'defence' ? 'investigate' : phase,
|
|
34
|
+
phase, title: info.title, responsibility: info.description, harness, model: config.model || null, skills: info.skills,
|
|
35
|
+
})),
|
|
36
|
+
operator_skills: [foundationSkill()],
|
|
37
|
+
automations: { supported: false, items: [] },
|
|
38
|
+
commands: Object.entries(phaseInfo).map(([name, info]) => ({ name, ...info, prompt: info.description,
|
|
39
|
+
executor: info.owner === 'agent' ? harnessOf(config) : 'factory', timeout: `${config.timeoutSeconds}s` })),
|
|
40
|
+
skills,
|
|
41
|
+
configuration: { harness, agent: harness, // agent is a v1 compatibility alias
|
|
42
|
+
model: config.model || null, check: config.check, timeoutSeconds: config.timeoutSeconds,
|
|
43
|
+
memoryMiB: config.memoryMiB, cpus: config.cpus || 2 },
|
|
44
|
+
method: {
|
|
45
|
+
preparation: ['factory-triage', 'factory-spec'], evaluation: ['factory-evaluate'],
|
|
46
|
+
instructions: 'All six skills are available read-only to agent phases. A skill is an instruction set, not a separate agent or an automatic workflow step.',
|
|
47
|
+
customization: 'Choose the harness, model, project check and resource limits in the installation’s private factory.json while stopped, then restart. Task scope belongs in the issue or work instructions. Phase order, gates and packaged skills change through a reviewed Factory release; they are not editable prompt templates.',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function foundationSkill() {
|
|
53
|
+
const path = 'operator-skills/factory-foundation/SKILL.md';
|
|
54
|
+
const content = readFileSync(join(ROOT, path), 'utf8');
|
|
55
|
+
return { id: 'factory-foundation', purpose: 'Prepare a repository and host for bounded Factory work', path, content, sha256: digest(content) };
|
|
56
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { harnessOf } from './lib.mjs';
|
|
1
2
|
import { hostname } from 'node:os';
|
|
2
3
|
import { digest } from './lib.mjs';
|
|
3
4
|
import { VERSION } from './updates.mjs';
|
|
@@ -6,11 +7,11 @@ import { usageFields } from './usage.mjs';
|
|
|
6
7
|
export function withRequestedModel(configuration, requestedModel) {
|
|
7
8
|
const config = structuredClone(configuration);
|
|
8
9
|
if (requestedModel && requestedModel !== config.model) {
|
|
9
|
-
if (!['codex', 'pi'].includes(config
|
|
10
|
+
if (!['codex', 'pi'].includes(harnessOf(config))) throw new Error('Task model overrides require codex or pi');
|
|
10
11
|
config.model = requestedModel;
|
|
11
12
|
const index = config.command.indexOf('--model');
|
|
12
13
|
if (index >= 0) config.command[index + 1] = requestedModel;
|
|
13
|
-
else config.command.splice(config
|
|
14
|
+
else config.command.splice(harnessOf(config) === 'codex' ? config.command.length - 1 : config.command.length, 0, '--model', requestedModel);
|
|
14
15
|
}
|
|
15
16
|
return config;
|
|
16
17
|
}
|
|
@@ -18,15 +19,15 @@ export function withRequestedModel(configuration, requestedModel) {
|
|
|
18
19
|
// Public facts only. The complete admitted configuration stays in private state.
|
|
19
20
|
export function executionProfile(config, phase) {
|
|
20
21
|
const deterministic = ['verify', 'handoff'].includes(phase);
|
|
21
|
-
const applicable = !deterministic && config
|
|
22
|
-
const provider = applicable && ['codex', 'pi'].includes(config
|
|
22
|
+
const applicable = !deterministic && harnessOf(config) !== 'mock';
|
|
23
|
+
const provider = applicable && ['codex', 'pi'].includes(harnessOf(config));
|
|
23
24
|
const requestedModel = provider ? config.model || null : null;
|
|
24
25
|
return {
|
|
25
|
-
version: 1, phase, executor: deterministic ? 'deterministic' : config
|
|
26
|
+
version: 1, phase, executor: deterministic ? 'deterministic' : harnessOf(config),
|
|
26
27
|
requestedModel,
|
|
27
28
|
modelSelection: !applicable ? 'not_applicable' : !provider ? 'unknown' : requestedModel ? 'explicit' : 'provider_default',
|
|
28
29
|
runtimeVersion: VERSION, image: phase === 'handoff' ? null : config.image,
|
|
29
|
-
policyHash: digest(JSON.stringify(config)),
|
|
30
|
+
policyHash: digest(JSON.stringify(config)), hostName: hostname(),
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -38,7 +39,8 @@ export function attemptPresentation(attempt, recoveredUsage) {
|
|
|
38
39
|
return {
|
|
39
40
|
...attempt, executor: profile?.executor ?? 'unknown',
|
|
40
41
|
...usage,
|
|
41
|
-
model: profile?.requestedModel ?? null,
|
|
42
|
+
model: profile?.requestedModel ?? null, host_name: profile?.hostName ?? profile?.workerName ?? null,
|
|
43
|
+
worker_name: profile?.hostName ?? profile?.workerName ?? null, // v1 presentation alias
|
|
42
44
|
provenance_status: profile ? 'recorded' : attempt.started_at ? 'unknown' : 'not_started',
|
|
43
45
|
};
|
|
44
46
|
}
|
package/factory/executor.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { harnessOf } from './lib.mjs';
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, lstatSync, chmodSync, readdirSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
@@ -73,7 +74,7 @@ async function container(mode, input, command, writable = false, credentials = f
|
|
|
73
74
|
if (mode === 'verify') args.push('--env',`FACTORY_BASE_REVISION=${git('rev-parse',`${metadata().base}^{commit}`)}`);
|
|
74
75
|
if (credentials) args.push('--env-file', join(state,'model.env'));
|
|
75
76
|
args.push('-i',config.image,'timeout','--signal=KILL',`${config.timeoutSeconds}s`,'sh','-c','mkdir -p "$HOME" && exec "$@"','factory',...command);
|
|
76
|
-
console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: config
|
|
77
|
+
console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: harnessOf(config) === 'mock' }));
|
|
77
78
|
const logPath = join(folder, attempt, `${mode}.log`);
|
|
78
79
|
const log = new BoundedLog(), usageParser = execution.executor === 'codex' ? new CodexUsageParser() : null; let exitSignal;
|
|
79
80
|
const code = await new Promise((ok, fail) => {
|
|
@@ -109,7 +110,7 @@ try {
|
|
|
109
110
|
if (existsSync(workspace)) throw new Error('Workspace already exists; preserve evidence and create a new task for a fresh build');
|
|
110
111
|
run('git',['-c','core.hooksPath=/dev/null','-c','core.fsmonitor=false','clone','--no-hardlinks','--',config.repo,workspace], { env: gitEnv });
|
|
111
112
|
git('remote','remove','origin');
|
|
112
|
-
save(join(folder,'candidate.json'), { base: git('rev-parse','HEAD'), head: git('rev-parse','HEAD'), synthetic: config
|
|
113
|
+
save(join(folder,'candidate.json'), { base: git('rev-parse','HEAD'), head: git('rev-parse','HEAD'), synthetic: harnessOf(config) === 'mock' });
|
|
113
114
|
}
|
|
114
115
|
if (phase === 'build') {
|
|
115
116
|
const reports = await container('build', brief('Implement the requested bounded change. Save /output/agent-report.md with actual changes and remaining uncertainty.'), config.command, true, true);
|
|
@@ -155,13 +156,13 @@ try {
|
|
|
155
156
|
save(incident.path,{...incident.entry,report:validated});candidate();
|
|
156
157
|
}
|
|
157
158
|
completed=true;
|
|
158
|
-
save(result,{ outcome:'complete', ...usageFields(observedUsage, execution, phase), ...(reviewVerdict ? {review_verdict:reviewVerdict} : {}), summary: phase === 'defence' ? 'Unverified private incident draft ready; recovery has not been verified.' : `${phase} complete; ${config
|
|
159
|
+
save(result,{ outcome:'complete', ...usageFields(observedUsage, execution, phase), ...(reviewVerdict ? {review_verdict:reviewVerdict} : {}), summary: phase === 'defence' ? 'Unverified private incident draft ready; recovery has not been verified.' : `${phase} complete; ${harnessOf(config) === 'mock' ? 'synthetic fixture' : 'see revision and evidence'}.` });
|
|
159
160
|
} catch (error) {
|
|
160
161
|
console.error(error.message);
|
|
161
162
|
save(result,{outcome:'blocked', ...usageFields(observedUsage, execution, phase), ...(reviewVerdict ? {review_verdict:reviewVerdict} : {}), summary:error.message});
|
|
162
163
|
process.exitCode=1;
|
|
163
164
|
} finally {
|
|
164
|
-
const measurement = { job, attempt, phase, policyHash, execution, ...usageFields(observedUsage, execution, phase), completed, durationMs: Date.now()-started, requestedModel: execution.requestedModel, directCost: null, humanTime: null, synthetic: config
|
|
165
|
+
const measurement = { job, attempt, phase, policyHash, execution, ...usageFields(observedUsage, execution, phase), completed, durationMs: Date.now()-started, requestedModel: execution.requestedModel, directCost: null, humanTime: null, synthetic: harnessOf(config) === 'mock' };
|
|
165
166
|
save(join(folder,`measurement-${attempt}.json`),measurement);save(join(output,`measurement-${attempt}.json`),measurement);
|
|
166
167
|
// If cleanup cannot be confirmed, retain the lock and require explicit recovery.
|
|
167
168
|
stopContainers(state,job);
|
package/factory/lib.mjs
CHANGED
|
@@ -26,9 +26,17 @@ export function stream(command, args, options = {}) {
|
|
|
26
26
|
}
|
|
27
27
|
export const digest = value => createHash('sha256').update(value).digest('hex');
|
|
28
28
|
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
29
|
+
export function harnessOf(config) {
|
|
30
|
+
if (config.harness !== undefined && config.agent !== undefined && config.harness !== config.agent)
|
|
31
|
+
throw new Error('harness conflicts with legacy agent; keep one harness setting');
|
|
32
|
+
const harness = config.harness ?? config.agent;
|
|
33
|
+
if (!['mock', 'codex', 'pi', 'custom'].includes(harness)) throw new Error('Unsupported harness');
|
|
34
|
+
return harness;
|
|
35
|
+
}
|
|
29
36
|
export function configAt(state) {
|
|
30
37
|
const config = json(join(state, 'factory.json'));
|
|
31
|
-
if (config.version !== 1
|
|
38
|
+
if (config.version !== 1) throw new Error('Unsupported configuration');
|
|
39
|
+
harnessOf(config);
|
|
32
40
|
if (!Array.isArray(config.command) || !config.command.length || !config.command.every(v => typeof v === 'string' && v && !v.includes('\0'))) throw new Error('command must be an argument array');
|
|
33
41
|
if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535) throw new Error('Invalid port');
|
|
34
42
|
if (!Number.isInteger(config.timeoutSeconds) || config.timeoutSeconds < 1 || config.timeoutSeconds > 7200) throw new Error('timeoutSeconds must be 1–7200');
|
package/factory/queue.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { existsSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
5
|
import { usageFields } from './usage.mjs';
|
|
6
6
|
|
|
7
|
-
import { WORKFLOWS as workflows } from './
|
|
7
|
+
import { WORKFLOWS as workflows } from './definition.mjs';
|
|
8
8
|
const id = prefix => prefix + '_' + randomBytes(12).toString('hex');
|
|
9
9
|
const now = () => new Date().toISOString();
|
|
10
10
|
export class QueueError extends Error { constructor(message, status = 409) { super(message); this.status = status; } }
|
package/factory/server.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { harnessOf } from './lib.mjs';
|
|
1
2
|
import http from 'node:http';
|
|
2
3
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
4
|
import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
4
5
|
import { join, resolve, sep } from 'node:path';
|
|
5
6
|
import { readIssue } from './issue-intake.mjs';
|
|
6
7
|
import { machineInfo } from './machine.mjs';
|
|
7
|
-
import {
|
|
8
|
+
import { factoryDefinition } from './definition.mjs';
|
|
8
9
|
import { JobQueue, QueueError } from './queue.mjs';
|
|
9
10
|
import { executors } from './processes.mjs';
|
|
10
11
|
import { configAt, ROOT } from './lib.mjs';
|
|
@@ -32,7 +33,7 @@ export function createController(state, adapter = executors(state)) {
|
|
|
32
33
|
const token = readFileSync(join(state, 'worker.token'), 'utf8').trim();
|
|
33
34
|
const queue = new JobQueue(state, adapter);
|
|
34
35
|
const projectLinks = readProjectLinks(config.repo);
|
|
35
|
-
const definitions =
|
|
36
|
+
const definitions = factoryDefinition(config), host = machineInfo();
|
|
36
37
|
const server = http.createServer(async (request, response) => {
|
|
37
38
|
const send = (status, value, type = 'application/json; charset=utf-8') => { response.writeHead(status, { 'Content-Type': type }); response.end(type.startsWith('application/json') ? JSON.stringify(value) : value); };
|
|
38
39
|
response.setHeader('Cache-Control', 'no-store'); response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('Referrer-Policy', 'no-referrer');
|
|
@@ -48,8 +49,10 @@ export function createController(state, adapter = executors(state)) {
|
|
|
48
49
|
const jobs = queue.all().map(job => ({ ...job, can_request_changes: queue.canRequestChanges(job), runs: job.runs.map(attempt => attemptPresentation({ ...attempt,
|
|
49
50
|
outcome: attempt.outcome || (attempt.state === 'succeeded' ? 'complete' : undefined) }, adapter.usage?.(job, attempt))) }));
|
|
50
51
|
return send(200, { version: 1, runtime_version: VERSION, maintenance: queue.maintenance, workflows: Object.keys(definitions.workflows), commands: [], triggers: [], jobs, csrf_token: csrf,
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
infrastructure: { host, controller: { connected: !queue.closing }, workers: [{ id: 'local-executor', name: 'Local worker', host: host.hostname, connected: !queue.closing }] },
|
|
53
|
+
automations: [],
|
|
54
|
+
workers: [{ name: 'Local worker', machine: host, instance_id: 'local-executor', repositories: ['app'], connected: !queue.closing, last_seen_at: new Date().toISOString() }], // v1 compatibility view
|
|
55
|
+
repositories: ['app'], repo: config.repo, project_links: projectLinks, harness: harnessOf(config), agent: harnessOf(config) });
|
|
53
56
|
}
|
|
54
57
|
if (request.method === 'GET' && url.pathname === '/api/v1/definitions') {
|
|
55
58
|
return send(200, definitions);
|
|
@@ -79,7 +82,7 @@ export function createController(state, adapter = executors(state)) {
|
|
|
79
82
|
catch (error) { throw new QueueError(error.message, 400); }
|
|
80
83
|
}
|
|
81
84
|
if (url.pathname === '/api/v1/jobs') {
|
|
82
|
-
if (input.model && input.model !== config.model && !['codex','pi'].includes(config
|
|
85
|
+
if (input.model && input.model !== config.model && !['codex','pi'].includes(harnessOf(config))) throw new QueueError('Model overrides require a codex or pi executor', 400);
|
|
83
86
|
return send(201, queue.submit(input));
|
|
84
87
|
}
|
|
85
88
|
const action = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/(approve|cancel|retry|request_changes)$/);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"host": "The machine that provides CPU, memory, storage and networking.",
|
|
3
|
+
"controller": "The service that owns one project's queue, policy, API and dashboard.",
|
|
4
|
+
"worker": "The execution process that runs isolated jobs on a host; not the host or an agent role.",
|
|
5
|
+
"harness": "The program that runs an agent, such as Codex, Pi or a custom command.",
|
|
6
|
+
"agent": "A configured work role with a responsibility and instructions, executed through a harness.",
|
|
7
|
+
"skill": "Reusable instructions available to an agent or operator; not a process or an access grant.",
|
|
8
|
+
"workflow": "The ordered agent steps, deterministic checks and approval gates for a work type.",
|
|
9
|
+
"task": "A bounded work request admitted to the controller; stored as a job with retained run attempts.",
|
|
10
|
+
"automation": "A trigger, filters and target that admits work automatically. Not yet implemented.",
|
|
11
|
+
"definition": "The effective work roles, workflows, skills and installation settings, separate from runtime records."
|
|
12
|
+
}
|