job-forge 2.14.13 → 2.14.15

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.
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'child_process';
4
+ import { existsSync } from 'fs';
5
+ import { dirname, join, relative, resolve } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { audit, formatAuditResult, formatPolicyExplanation, loadPolicy, resultFails } from '@razroo/iso-guard';
8
+ import { defaultOpenCodeDbPath, findSessionById, parseSinceCutoff } from '@razroo/iso-trace';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PKG_ROOT = resolve(__dirname, '..');
12
+ const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
13
+ const DEFAULT_SINCE = '24h';
14
+
15
+ const USAGE = `job-forge guard - deterministic JobForge policy audits over local OpenCode traces
16
+
17
+ Usage:
18
+ job-forge guard:audit [latest|<id-or-prefix>] [--since 24h] [--cwd <dir>] [--policy <path>] [--json] [--fail-on error|warn|off] [--root-only]
19
+ job-forge guard:explain [--policy <path>] [--json]
20
+
21
+ The default policy is templates/guards/jobforge-baseline.yaml. Guard audits are
22
+ local-only and passive: JobForge converts OpenCode SQLite rows into iso-guard
23
+ events and never asks agents or MCPs to emit extra telemetry.`;
24
+
25
+ const [cmd = 'help', ...args] = process.argv.slice(2);
26
+
27
+ function defaultPolicyPath() {
28
+ return join(PKG_ROOT, 'templates/guards/jobforge-baseline.yaml');
29
+ }
30
+
31
+ function parseArgs(rawArgs, { allowSession = false } = {}) {
32
+ const opts = {
33
+ since: DEFAULT_SINCE,
34
+ cwd: PROJECT_DIR,
35
+ policy: defaultPolicyPath(),
36
+ json: false,
37
+ failOn: 'error',
38
+ includeChildren: true,
39
+ };
40
+ const positional = [];
41
+
42
+ for (let i = 0; i < rawArgs.length; i++) {
43
+ const arg = rawArgs[i];
44
+ if (arg === '--since') {
45
+ opts.since = valueAfter(rawArgs, ++i, '--since');
46
+ } else if (arg.startsWith('--since=')) {
47
+ opts.since = arg.slice('--since='.length);
48
+ } else if (arg === '--cwd') {
49
+ opts.cwd = valueAfter(rawArgs, ++i, '--cwd');
50
+ } else if (arg.startsWith('--cwd=')) {
51
+ opts.cwd = arg.slice('--cwd='.length);
52
+ } else if (arg === '--policy') {
53
+ opts.policy = valueAfter(rawArgs, ++i, '--policy');
54
+ } else if (arg.startsWith('--policy=')) {
55
+ opts.policy = arg.slice('--policy='.length);
56
+ } else if (arg === '--fail-on') {
57
+ opts.failOn = valueAfter(rawArgs, ++i, '--fail-on');
58
+ } else if (arg.startsWith('--fail-on=')) {
59
+ opts.failOn = arg.slice('--fail-on='.length);
60
+ } else if (arg === '--json') {
61
+ opts.json = true;
62
+ } else if (arg === '--root-only') {
63
+ opts.includeChildren = false;
64
+ } else if (arg === '--help' || arg === '-h') {
65
+ opts.help = true;
66
+ } else if (arg.startsWith('--')) {
67
+ opts.error = `unknown flag "${arg}"`;
68
+ } else if (allowSession) {
69
+ positional.push(arg);
70
+ } else {
71
+ opts.error = `unexpected argument "${arg}"`;
72
+ }
73
+ }
74
+
75
+ opts.cwd = resolve(opts.cwd || PROJECT_DIR);
76
+ opts.policy = resolve(opts.policy || defaultPolicyPath());
77
+ if (!['error', 'warn', 'off'].includes(opts.failOn)) {
78
+ opts.error = '--fail-on must be one of: error, warn, off';
79
+ }
80
+ return { opts, positional };
81
+ }
82
+
83
+ function valueAfter(values, index, flag) {
84
+ const value = values[index];
85
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
86
+ return value;
87
+ }
88
+
89
+ function queryOpenCodeDb(dbPath, sql) {
90
+ const result = spawnSync('sqlite3', ['-json', dbPath, sql], {
91
+ encoding: 'utf8',
92
+ maxBuffer: 32 * 1024 * 1024,
93
+ });
94
+ if ((result.status ?? 0) !== 0) {
95
+ const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 1}`;
96
+ throw new Error(`job-forge guard: sqlite3 query failed: ${detail}`);
97
+ }
98
+ return JSON.parse(result.stdout || '[]');
99
+ }
100
+
101
+ function sqlString(value) {
102
+ return `'${String(value).replaceAll("'", "''")}'`;
103
+ }
104
+
105
+ function msToIso(ms) {
106
+ return new Date(Number(ms)).toISOString();
107
+ }
108
+
109
+ function discoverSessions(opts, { includeAllForShow = false } = {}) {
110
+ const dbPath = defaultOpenCodeDbPath();
111
+ if (!existsSync(dbPath)) return [];
112
+
113
+ const where = [
114
+ 's.time_archived is null',
115
+ `s.directory = ${sqlString(opts.cwd)}`,
116
+ ];
117
+ const sinceMs = includeAllForShow ? undefined : parseSinceCutoff(opts.since);
118
+ if (sinceMs !== undefined) where.push(`s.time_created >= ${Number(sinceMs)}`);
119
+
120
+ const rows = queryOpenCodeDb(dbPath, [
121
+ 'select',
122
+ ' s.id,',
123
+ ' s.parent_id,',
124
+ ' s.title,',
125
+ ' s.directory,',
126
+ ' s.time_created,',
127
+ ' s.time_updated,',
128
+ ' (select count(*) from message m where m.session_id = s.id) as turn_count,',
129
+ ' (',
130
+ ' (select coalesce(sum(length(data)), 0) from message m where m.session_id = s.id) +',
131
+ ' (select coalesce(sum(length(data)), 0) from part p where p.session_id = s.id)',
132
+ ' ) as size_bytes',
133
+ 'from session s',
134
+ `where ${where.join(' and ')}`,
135
+ 'order by s.time_updated desc',
136
+ ].join(' '));
137
+
138
+ return rows.map((row) => ({
139
+ id: row.id,
140
+ parentId: row.parent_id || null,
141
+ title: row.title || '',
142
+ cwd: row.directory,
143
+ startedAt: msToIso(row.time_created),
144
+ startedAtMs: Number(row.time_created),
145
+ endedAt: msToIso(row.time_updated),
146
+ endedAtMs: Number(row.time_updated),
147
+ turnCount: row.turn_count ?? 0,
148
+ sizeBytes: row.size_bytes ?? 0,
149
+ }));
150
+ }
151
+
152
+ function loadRows(sessionId) {
153
+ const dbPath = defaultOpenCodeDbPath();
154
+ const id = sqlString(sessionId);
155
+ return {
156
+ messages: queryOpenCodeDb(dbPath, `select id, time_created, data from message where session_id = ${id} order by time_created, id`),
157
+ parts: queryOpenCodeDb(dbPath, `select id, message_id, time_created, data from part where session_id = ${id} order by time_created, id`),
158
+ };
159
+ }
160
+
161
+ function parseJson(raw) {
162
+ try {
163
+ return JSON.parse(raw || '{}');
164
+ } catch (error) {
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ return { __parseError: message, __raw: raw || '' };
167
+ }
168
+ }
169
+
170
+ function selectSession(refs, positional) {
171
+ const requested = positional[0];
172
+ if (!requested || requested === 'latest') {
173
+ return refs.find((ref) => !ref.parentId) || refs[0] || null;
174
+ }
175
+ return findSessionById(refs, requested);
176
+ }
177
+
178
+ function sessionsForAudit(selected, allSessions, includeChildren) {
179
+ if (!includeChildren) return [selected];
180
+ const children = allSessions
181
+ .filter((candidate) => candidate.parentId === selected.id)
182
+ .sort((a, b) => a.startedAtMs - b.startedAtMs);
183
+ return [selected, ...children];
184
+ }
185
+
186
+ function buildGuardEvents(sessions) {
187
+ const events = [];
188
+ const rootId = sessions[0]?.id;
189
+
190
+ for (const session of sessions) {
191
+ const rows = loadRows(session.id);
192
+ const messages = rows.messages.map((row) => ({ row, data: parseJson(row.data) }));
193
+ const messageById = new Map(messages.map((message) => [message.row.id, message.data]));
194
+ let requestIndex = 0;
195
+
196
+ for (const row of rows.parts) {
197
+ const data = parseJson(row.data);
198
+ const message = messageById.get(row.message_id) || {};
199
+ const role = message.role || 'unknown';
200
+ const at = msToIso(row.time_created);
201
+ const base = {
202
+ sessionId: session.id,
203
+ sessionTitle: session.title,
204
+ parentId: session.parentId,
205
+ isChildSession: session.id !== rootId,
206
+ role,
207
+ messageId: row.message_id,
208
+ sessionMessageId: `${session.id}:${row.message_id}`,
209
+ partId: row.id,
210
+ requestIndex,
211
+ };
212
+
213
+ if (data.type === 'text') {
214
+ if (role === 'user') requestIndex += 1;
215
+ events.push({
216
+ type: 'message',
217
+ name: role,
218
+ at,
219
+ source: `opencode:${session.id}`,
220
+ text: data.text || '',
221
+ data: { ...base, requestIndex },
222
+ });
223
+ continue;
224
+ }
225
+
226
+ if (data.type === 'tool') {
227
+ const input = objectOrEmpty(data.state?.input);
228
+ const metadata = objectOrEmpty(data.state?.metadata);
229
+ const toolName = data.tool || 'unknown';
230
+ const text = toolText(toolName, input, metadata, data.state);
231
+ const toolEvent = {
232
+ type: 'tool_call',
233
+ name: toolName,
234
+ at,
235
+ source: `opencode:${session.id}`,
236
+ text,
237
+ data: {
238
+ ...base,
239
+ tool: toolName,
240
+ status: stringValue(data.state?.status),
241
+ input,
242
+ metadata,
243
+ },
244
+ };
245
+ events.push(toolEvent);
246
+ events.push(...derivedToolEvents(toolEvent));
247
+ }
248
+ }
249
+ }
250
+
251
+ return events
252
+ .sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
253
+ .map((event, index) => ({ ...event, index }));
254
+ }
255
+
256
+ function derivedToolEvents(event) {
257
+ const text = event.text || '';
258
+ const events = [];
259
+ if (runsCommand(text, /\b(npx\s+)?job-forge\s+merge\b|\bnpm\s+run\s+merge\b/)) {
260
+ events.push(derivedToolEvent(event, 'job-forge-merge'));
261
+ }
262
+ if (runsCommand(text, /\b(npx\s+)?job-forge\s+verify\b|\bnpm\s+run\s+verify\b/)) {
263
+ events.push(derivedToolEvent(event, 'job-forge-verify'));
264
+ }
265
+ if (runsCommand(text, /\bgeometra_disconnect\b/)) {
266
+ events.push(derivedToolEvent(event, 'geometra_disconnect'));
267
+ }
268
+ if (runsCommand(text, /\bgeometra_list_sessions\b/)) {
269
+ events.push(derivedToolEvent(event, 'geometra_list_sessions'));
270
+ }
271
+ return events;
272
+ }
273
+
274
+ function derivedToolEvent(event, name) {
275
+ return {
276
+ ...event,
277
+ name,
278
+ data: {
279
+ ...(event.data || {}),
280
+ derivedFrom: event.name,
281
+ },
282
+ };
283
+ }
284
+
285
+ function runsCommand(text, pattern) {
286
+ return /(^|[\s"])(bash|shell|exec|command|terminal|run_command)\b/i.test(text) && pattern.test(text);
287
+ }
288
+
289
+ function toolText(toolName, input, metadata, state) {
290
+ const fragments = [toolName, safeJson(input), safeJson(metadata)];
291
+ if (state?.output && typeof state.output === 'string') fragments.push(state.output);
292
+ return fragments.filter(Boolean).join('\n');
293
+ }
294
+
295
+ function objectOrEmpty(value) {
296
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
297
+ }
298
+
299
+ function stringValue(value) {
300
+ return typeof value === 'string' ? value : value == null ? '' : String(value);
301
+ }
302
+
303
+ function safeJson(value) {
304
+ try {
305
+ return JSON.stringify(value);
306
+ } catch {
307
+ return '';
308
+ }
309
+ }
310
+
311
+ function printablePath(path) {
312
+ const rel = relative(PROJECT_DIR, path);
313
+ return rel && !rel.startsWith('..') ? rel : path;
314
+ }
315
+
316
+ function printAudit({ selected, includedSessions, policy, result }) {
317
+ const children = includedSessions.length - 1;
318
+ console.log(`session: ${selected.id}${selected.title ? ` (${selected.title})` : ''}`);
319
+ if (children > 0) console.log(`children: ${children}`);
320
+ console.log(`policy: ${printablePath(policy.sourcePath || defaultPolicyPath())}`);
321
+ console.log(formatAuditResult(result));
322
+ }
323
+
324
+ async function main() {
325
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
326
+ console.log(USAGE);
327
+ return 0;
328
+ }
329
+
330
+ if (cmd === 'explain') {
331
+ const { opts } = parseArgs(args);
332
+ if (opts.help) {
333
+ console.log(USAGE);
334
+ return 0;
335
+ }
336
+ if (opts.error) {
337
+ console.error(`job-forge guard:explain: ${opts.error}`);
338
+ return 2;
339
+ }
340
+ const policy = loadPolicy(opts.policy);
341
+ if (opts.json) {
342
+ console.log(JSON.stringify(policy, null, 2));
343
+ } else {
344
+ console.log(formatPolicyExplanation(policy));
345
+ }
346
+ return 0;
347
+ }
348
+
349
+ if (cmd === 'audit') {
350
+ const { opts, positional } = parseArgs(args, { allowSession: true });
351
+ if (opts.help) {
352
+ console.log(USAGE);
353
+ return 0;
354
+ }
355
+ if (opts.error) {
356
+ console.error(`job-forge guard:audit: ${opts.error}`);
357
+ return 2;
358
+ }
359
+ const refs = discoverSessions(opts, { includeAllForShow: Boolean(positional[0] && positional[0] !== 'latest') });
360
+ if (refs.length === 0) {
361
+ console.error('job-forge guard:audit: no OpenCode sessions found for this project');
362
+ return 2;
363
+ }
364
+ const selected = selectSession(refs, positional);
365
+ if (!selected) {
366
+ console.error(`job-forge guard:audit: no OpenCode session matches "${positional[0]}"`);
367
+ return 2;
368
+ }
369
+ const includedSessions = sessionsForAudit(selected, refs, opts.includeChildren);
370
+ const policy = loadPolicy(opts.policy);
371
+ const events = buildGuardEvents(includedSessions);
372
+ const result = audit(policy, events);
373
+
374
+ if (opts.json) {
375
+ console.log(JSON.stringify({
376
+ session: selected,
377
+ includedSessions: includedSessions.map((session) => ({
378
+ id: session.id,
379
+ parentId: session.parentId,
380
+ title: session.title,
381
+ startedAt: session.startedAt,
382
+ endedAt: session.endedAt,
383
+ })),
384
+ policy: policy.sourcePath,
385
+ result,
386
+ }, null, 2));
387
+ } else {
388
+ printAudit({ selected, includedSessions, policy, result });
389
+ }
390
+ return resultFails(result, opts.failOn) ? 1 : 0;
391
+ }
392
+
393
+ console.error(`job-forge guard: unknown command "${cmd}"`);
394
+ console.error(USAGE);
395
+ return 2;
396
+ }
397
+
398
+ main()
399
+ .then((code) => process.exit(code))
400
+ .catch((error) => {
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ console.error(message);
403
+ process.exit(1);
404
+ });
@@ -0,0 +1,50 @@
1
+ version: 1
2
+ rules:
3
+ - id: JF-H1
4
+ type: max-per-group
5
+ severity: error
6
+ description: Do not dispatch more than two task subagents from one assistant message.
7
+ match:
8
+ type: tool_call
9
+ name: task
10
+ groupBy: sessionMessageId
11
+ max: 2
12
+
13
+ - id: JF-H5b
14
+ type: forbid-text
15
+ severity: error
16
+ description: Do not use task to poll task/session status.
17
+ match:
18
+ type: tool_call
19
+ name: task
20
+ patterns:
21
+ - source: '"task_id"\s*:'
22
+ - source: '\b(return your final outcome now|report your current status|current status|still working|still running)\b'
23
+ flags: i
24
+ - source: '\b(check|poll|fetch|ask)\b.{0,80}\b(task|session)\b.{0,60}\b(status|state|result|outcome)\b'
25
+ flags: i
26
+
27
+ - id: JF-H5-child-no-task
28
+ type: forbid-text
29
+ severity: error
30
+ description: Child/subagent sessions must not spawn more task subagents.
31
+ match:
32
+ type: tool_call
33
+ name: task
34
+ fields:
35
+ isChildSession: true
36
+ patterns:
37
+ - source: '[\s\S]'
38
+
39
+ - id: JF-H8
40
+ type: forbid-text
41
+ severity: error
42
+ description: Task prompts must not inline proxy configuration or proxy credentials.
43
+ match:
44
+ type: tool_call
45
+ name: task
46
+ patterns:
47
+ - source: '\bproxy\s*:\s*\{'
48
+ flags: i
49
+ - source: '\bproxy[_.-]?(server|username|password|bypass)\b\s*[:=]'
50
+ flags: i