crewly 1.11.5 → 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/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
- package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
- package/config/skills/agent/web-search/SKILL.md +70 -0
- package/config/skills/agent/web-search/execute.sh +170 -0
- package/config/skills/agent/web-search/skill.json +23 -0
- package/dist/backend/backend/src/constants.d.ts +34 -1
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +34 -1
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +36 -2
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts +90 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.js +309 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.js +134 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts +78 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.js +358 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup.types.d.ts +163 -0
- package/dist/backend/backend/src/services/backup/backup.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup.types.js +13 -0
- package/dist/backend/backend/src/services/backup/backup.types.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts +29 -2
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.js +97 -13
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +164 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.js +67 -2
- package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
- package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
- package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
- package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
- package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/intent-task.types.js +8 -0
- package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.js +1 -0
- package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +34 -1
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +34 -1
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts +70 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts.map +1 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js +427 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts +90 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.js +309 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.js +134 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts +78 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.js +358 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup.types.d.ts +163 -0
- package/dist/cli/backend/src/services/backup/backup.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup.types.js +13 -0
- package/dist/cli/backend/src/services/backup/backup.types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts +410 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js +863 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts +292 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.js +1093 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +328 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js +171 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts +89 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.js +148 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.js.map +1 -0
- package/dist/cli/backend/src/services/user/user-identity.service.d.ts +86 -0
- package/dist/cli/backend/src/services/user/user-identity.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/user/user-identity.service.js +190 -0
- package/dist/cli/backend/src/services/user/user-identity.service.js.map +1 -0
- package/dist/cli/cli/src/commands/backup.d.ts +31 -0
- package/dist/cli/cli/src/commands/backup.d.ts.map +1 -0
- package/dist/cli/cli/src/commands/backup.js +280 -0
- package/dist/cli/cli/src/commands/backup.js.map +1 -0
- package/dist/cli/cli/src/index.js +10 -0
- package/dist/cli/cli/src/index.js.map +1 -1
- package/package.json +9 -3
- package/packages/crewly-agent/README.md +27 -0
- package/packages/crewly-agent/bin/crewly-agent +33 -0
- package/packages/crewly-agent/package.json +39 -0
- package/packages/crewly-agent/src/cli.ts +168 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
- package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
- package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
- package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
- package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
- package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
- package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
- package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
- package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
- package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
- package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
- package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
- package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
- package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
- package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
- package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
- package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
- package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
- package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
- package/packages/crewly-agent/src/runtime/index.ts +38 -0
- package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
- package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
- package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
- package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
- package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
- package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
- package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
- package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
- package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
- package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
- package/packages/crewly-agent/src/runtime/types.ts +637 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fsPromises } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { AuditTrailService } from './audit-trail.service.js';
|
|
6
|
+
import type { AuditEntry } from './types.js';
|
|
7
|
+
|
|
8
|
+
describe('AuditTrailService', () => {
|
|
9
|
+
let service: AuditTrailService;
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tempDir = await fsPromises.mkdtemp(join(tmpdir(), 'crewly-audit-test-'));
|
|
14
|
+
service = new AuditTrailService(tempDir, 'test-agent-session');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await fsPromises.rm(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const makeEntry = (overrides?: Partial<AuditEntry>): AuditEntry => ({
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
sessionName: 'test-agent-session',
|
|
24
|
+
toolName: 'get_team_status',
|
|
25
|
+
sensitivity: 'safe',
|
|
26
|
+
args: {},
|
|
27
|
+
success: true,
|
|
28
|
+
durationMs: 15,
|
|
29
|
+
...overrides,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('initialize', () => {
|
|
33
|
+
it('should create the audit-logs directory', async () => {
|
|
34
|
+
await service.initialize();
|
|
35
|
+
|
|
36
|
+
const stat = await fsPromises.stat(join(tempDir, 'audit-logs'));
|
|
37
|
+
expect(stat.isDirectory()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should set initialized flag', async () => {
|
|
41
|
+
expect(service.isInitialized()).toBe(false);
|
|
42
|
+
await service.initialize();
|
|
43
|
+
expect(service.isInitialized()).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should be idempotent (safe to call multiple times)', async () => {
|
|
47
|
+
await service.initialize();
|
|
48
|
+
await service.initialize();
|
|
49
|
+
expect(service.isInitialized()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('append', () => {
|
|
54
|
+
it('should throw if not initialized', async () => {
|
|
55
|
+
await expect(service.append(makeEntry())).rejects.toThrow('not initialized');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should write entry as JSONL line', async () => {
|
|
59
|
+
await service.initialize();
|
|
60
|
+
const entry = makeEntry();
|
|
61
|
+
await service.append(entry);
|
|
62
|
+
|
|
63
|
+
const content = await fsPromises.readFile(service.getLogFilePath(), 'utf8');
|
|
64
|
+
const parsed = JSON.parse(content.trim());
|
|
65
|
+
expect(parsed.toolName).toBe('get_team_status');
|
|
66
|
+
expect(parsed.sessionName).toBe('test-agent-session');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should append multiple entries on separate lines', async () => {
|
|
70
|
+
await service.initialize();
|
|
71
|
+
await service.append(makeEntry({ toolName: 'tool_a' }));
|
|
72
|
+
await service.append(makeEntry({ toolName: 'tool_b' }));
|
|
73
|
+
await service.append(makeEntry({ toolName: 'tool_c' }));
|
|
74
|
+
|
|
75
|
+
const content = await fsPromises.readFile(service.getLogFilePath(), 'utf8');
|
|
76
|
+
const lines = content.trim().split('\n');
|
|
77
|
+
expect(lines).toHaveLength(3);
|
|
78
|
+
expect(JSON.parse(lines[0]).toolName).toBe('tool_a');
|
|
79
|
+
expect(JSON.parse(lines[2]).toolName).toBe('tool_c');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('query', () => {
|
|
84
|
+
it('should throw if not initialized', async () => {
|
|
85
|
+
await expect(service.query({ limit: 10 })).rejects.toThrow('not initialized');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return empty array when log file does not exist', async () => {
|
|
89
|
+
await service.initialize();
|
|
90
|
+
const entries = await service.query({ limit: 50 });
|
|
91
|
+
expect(entries).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return entries in reverse chronological order', async () => {
|
|
95
|
+
await service.initialize();
|
|
96
|
+
await service.append(makeEntry({ toolName: 'first', timestamp: '2026-01-01T00:00:00Z' }));
|
|
97
|
+
await service.append(makeEntry({ toolName: 'second', timestamp: '2026-01-01T00:01:00Z' }));
|
|
98
|
+
await service.append(makeEntry({ toolName: 'third', timestamp: '2026-01-01T00:02:00Z' }));
|
|
99
|
+
|
|
100
|
+
const entries = await service.query({ limit: 50 });
|
|
101
|
+
expect(entries).toHaveLength(3);
|
|
102
|
+
expect(entries[0].toolName).toBe('third');
|
|
103
|
+
expect(entries[2].toolName).toBe('first');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should respect limit parameter', async () => {
|
|
107
|
+
await service.initialize();
|
|
108
|
+
for (let i = 0; i < 10; i++) {
|
|
109
|
+
await service.append(makeEntry({ toolName: `tool_${i}` }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const entries = await service.query({ limit: 3 });
|
|
113
|
+
expect(entries).toHaveLength(3);
|
|
114
|
+
expect(entries[0].toolName).toBe('tool_9');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should filter by sensitivity', async () => {
|
|
118
|
+
await service.initialize();
|
|
119
|
+
await service.append(makeEntry({ toolName: 'safe_tool', sensitivity: 'safe' }));
|
|
120
|
+
await service.append(makeEntry({ toolName: 'sensitive_tool', sensitivity: 'sensitive' }));
|
|
121
|
+
await service.append(makeEntry({ toolName: 'destructive_tool', sensitivity: 'destructive' }));
|
|
122
|
+
|
|
123
|
+
const entries = await service.query({ limit: 50, sensitivity: 'sensitive' });
|
|
124
|
+
expect(entries).toHaveLength(1);
|
|
125
|
+
expect(entries[0].toolName).toBe('sensitive_tool');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should filter by toolName', async () => {
|
|
129
|
+
await service.initialize();
|
|
130
|
+
await service.append(makeEntry({ toolName: 'edit_file' }));
|
|
131
|
+
await service.append(makeEntry({ toolName: 'read_file' }));
|
|
132
|
+
await service.append(makeEntry({ toolName: 'edit_file' }));
|
|
133
|
+
|
|
134
|
+
const entries = await service.query({ limit: 50, toolName: 'edit_file' });
|
|
135
|
+
expect(entries).toHaveLength(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should combine sensitivity and toolName filters', async () => {
|
|
139
|
+
await service.initialize();
|
|
140
|
+
await service.append(makeEntry({ toolName: 'edit_file', sensitivity: 'destructive' }));
|
|
141
|
+
await service.append(makeEntry({ toolName: 'read_file', sensitivity: 'safe' }));
|
|
142
|
+
await service.append(makeEntry({ toolName: 'edit_file', sensitivity: 'safe' }));
|
|
143
|
+
|
|
144
|
+
const entries = await service.query({ limit: 50, sensitivity: 'destructive', toolName: 'edit_file' });
|
|
145
|
+
expect(entries).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should skip malformed lines gracefully', async () => {
|
|
149
|
+
await service.initialize();
|
|
150
|
+
await service.append(makeEntry({ toolName: 'valid' }));
|
|
151
|
+
// Manually write a malformed line
|
|
152
|
+
await fsPromises.appendFile(service.getLogFilePath(), 'not-valid-json\n', 'utf8');
|
|
153
|
+
await service.append(makeEntry({ toolName: 'also_valid' }));
|
|
154
|
+
|
|
155
|
+
const entries = await service.query({ limit: 50 });
|
|
156
|
+
expect(entries).toHaveLength(2);
|
|
157
|
+
expect(entries[0].toolName).toBe('also_valid');
|
|
158
|
+
expect(entries[1].toolName).toBe('valid');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('getLogFilePath', () => {
|
|
163
|
+
it('should return path under audit-logs directory', () => {
|
|
164
|
+
const path = service.getLogFilePath();
|
|
165
|
+
expect(path).toContain('audit-logs');
|
|
166
|
+
expect(path).toContain('test-agent-session');
|
|
167
|
+
expect(path.endsWith('.jsonl')).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should sanitize special characters in session name', () => {
|
|
171
|
+
const specialService = new AuditTrailService(tempDir, 'agent/with spaces!');
|
|
172
|
+
const path = specialService.getLogFilePath();
|
|
173
|
+
expect(path).not.toContain('/with');
|
|
174
|
+
expect(path).not.toContain(' ');
|
|
175
|
+
expect(path).not.toContain('!');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Trail Service
|
|
3
|
+
*
|
|
4
|
+
* Provides file-based persistence for the security audit trail.
|
|
5
|
+
* Writes audit entries as append-only JSONL (one JSON object per line)
|
|
6
|
+
* for structured, queryable audit logging.
|
|
7
|
+
*
|
|
8
|
+
* @module services/agent/crewly-agent/audit-trail.service
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fsPromises } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import type { AuditEntry, AuditLogFilters } from './types.js';
|
|
14
|
+
|
|
15
|
+
/** Default directory name for audit logs under .crewly */
|
|
16
|
+
const AUDIT_DIR = 'audit-logs';
|
|
17
|
+
|
|
18
|
+
/** Maximum lines to read when querying the log file */
|
|
19
|
+
const MAX_QUERY_LINES = 10000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* File-based audit trail service.
|
|
23
|
+
*
|
|
24
|
+
* Persists audit entries as append-only JSONL files organized by session.
|
|
25
|
+
* Each session gets its own log file for easy per-agent auditing.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const trail = new AuditTrailService('/path/to/.crewly', 'agent-session-1');
|
|
30
|
+
* await trail.initialize();
|
|
31
|
+
* await trail.append(auditEntry);
|
|
32
|
+
* const entries = await trail.query({ limit: 50 });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export class AuditTrailService {
|
|
36
|
+
private crewlyHome: string;
|
|
37
|
+
private sessionName: string;
|
|
38
|
+
private logDir: string;
|
|
39
|
+
private logFile: string;
|
|
40
|
+
private initialized = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a new AuditTrailService.
|
|
44
|
+
*
|
|
45
|
+
* @param crewlyHome - Path to the .crewly directory
|
|
46
|
+
* @param sessionName - Agent session name for log file naming
|
|
47
|
+
*/
|
|
48
|
+
constructor(crewlyHome: string, sessionName: string) {
|
|
49
|
+
this.crewlyHome = crewlyHome;
|
|
50
|
+
this.sessionName = sessionName;
|
|
51
|
+
this.logDir = join(crewlyHome, AUDIT_DIR);
|
|
52
|
+
this.logFile = join(this.logDir, `${sanitizeFilename(sessionName)}.jsonl`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Initialize the audit trail by ensuring the log directory exists.
|
|
57
|
+
*
|
|
58
|
+
* @throws Error if directory creation fails
|
|
59
|
+
*/
|
|
60
|
+
async initialize(): Promise<void> {
|
|
61
|
+
await fsPromises.mkdir(this.logDir, { recursive: true });
|
|
62
|
+
this.initialized = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Append an audit entry to the log file.
|
|
67
|
+
*
|
|
68
|
+
* @param entry - Audit entry to persist
|
|
69
|
+
* @throws Error if not initialized or write fails
|
|
70
|
+
*/
|
|
71
|
+
async append(entry: AuditEntry): Promise<void> {
|
|
72
|
+
if (!this.initialized) {
|
|
73
|
+
throw new Error('AuditTrailService not initialized. Call initialize() first.');
|
|
74
|
+
}
|
|
75
|
+
const line = JSON.stringify(entry) + '\n';
|
|
76
|
+
await fsPromises.appendFile(this.logFile, line, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Query persisted audit entries with optional filters.
|
|
81
|
+
*
|
|
82
|
+
* Reads the log file and returns matching entries in reverse
|
|
83
|
+
* chronological order (most recent first).
|
|
84
|
+
*
|
|
85
|
+
* @param filters - Query filters for limit, sensitivity, and toolName
|
|
86
|
+
* @returns Filtered audit entries
|
|
87
|
+
*/
|
|
88
|
+
async query(filters: AuditLogFilters): Promise<AuditEntry[]> {
|
|
89
|
+
if (!this.initialized) {
|
|
90
|
+
throw new Error('AuditTrailService not initialized. Call initialize() first.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let content: string;
|
|
94
|
+
try {
|
|
95
|
+
content = await fsPromises.readFile(this.logFile, 'utf8');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// File doesn't exist yet — no entries
|
|
98
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
105
|
+
|
|
106
|
+
// Parse from end (most recent first), apply filters
|
|
107
|
+
let entries: AuditEntry[] = [];
|
|
108
|
+
const startIdx = Math.max(0, lines.length - MAX_QUERY_LINES);
|
|
109
|
+
for (let i = lines.length - 1; i >= startIdx && entries.length < filters.limit; i--) {
|
|
110
|
+
try {
|
|
111
|
+
const entry: AuditEntry = JSON.parse(lines[i]);
|
|
112
|
+
if (filters.sensitivity && entry.sensitivity !== filters.sensitivity) continue;
|
|
113
|
+
if (filters.toolName && entry.toolName !== filters.toolName) continue;
|
|
114
|
+
entries.push(entry);
|
|
115
|
+
} catch {
|
|
116
|
+
// Skip malformed lines
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the path to the audit log file.
|
|
125
|
+
*
|
|
126
|
+
* @returns Absolute path to the JSONL log file
|
|
127
|
+
*/
|
|
128
|
+
getLogFilePath(): string {
|
|
129
|
+
return this.logFile;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if the service has been initialized.
|
|
134
|
+
*
|
|
135
|
+
* @returns True if initialize() has been called successfully
|
|
136
|
+
*/
|
|
137
|
+
isInitialized(): boolean {
|
|
138
|
+
return this.initialized;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Sanitize a session name for use as a filename.
|
|
144
|
+
* Replaces non-alphanumeric characters (except hyphens and underscores) with hyphens.
|
|
145
|
+
*
|
|
146
|
+
* @param name - Raw session name
|
|
147
|
+
* @returns Sanitized filename-safe string
|
|
148
|
+
*/
|
|
149
|
+
function sanitizeFilename(name: string): string {
|
|
150
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
151
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi, type Mocked, type MockInstance } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { createAuditorTools, getAuditorToolNames } from './auditor-tools.js';
|
|
5
|
+
import { CrewlyApiClient } from './api-client.js';
|
|
6
|
+
|
|
7
|
+
describe('Auditor Tools', () => {
|
|
8
|
+
let mockClient: Mocked<CrewlyApiClient>;
|
|
9
|
+
let tools: ReturnType<typeof createAuditorTools>;
|
|
10
|
+
let readFileSpy: MockInstance<typeof fs.readFile>;
|
|
11
|
+
let writeFileSpy: MockInstance<typeof fs.writeFile>;
|
|
12
|
+
let mkdirSpy: MockInstance<typeof fs.mkdir>;
|
|
13
|
+
const projectPath = '/test/project';
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
mockClient = {
|
|
18
|
+
get: vi.fn<any>(),
|
|
19
|
+
post: vi.fn<any>(),
|
|
20
|
+
put: vi.fn<any>(),
|
|
21
|
+
delete: vi.fn<any>(),
|
|
22
|
+
} as any;
|
|
23
|
+
|
|
24
|
+
readFileSpy = vi.spyOn(fs, 'readFile') as any;
|
|
25
|
+
writeFileSpy = vi.spyOn(fs, 'writeFile') as any;
|
|
26
|
+
mkdirSpy = vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as any) as any;
|
|
27
|
+
|
|
28
|
+
tools = createAuditorTools(mockClient, projectPath);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('getAuditorToolNames', () => {
|
|
36
|
+
it('should return all 11 tool names', () => {
|
|
37
|
+
const names = getAuditorToolNames();
|
|
38
|
+
expect(names).toHaveLength(11);
|
|
39
|
+
expect(names).toContain('get_team_status');
|
|
40
|
+
expect(names).toContain('get_agent_logs');
|
|
41
|
+
expect(names).toContain('get_tasks');
|
|
42
|
+
expect(names).toContain('recall_goals');
|
|
43
|
+
expect(names).toContain('heartbeat');
|
|
44
|
+
expect(names).toContain('get_agent_status');
|
|
45
|
+
expect(names).toContain('subscribe_event');
|
|
46
|
+
expect(names).toContain('write_audit_report');
|
|
47
|
+
expect(names).toContain('reply_slack');
|
|
48
|
+
expect(names).toContain('read_audit_history');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('tool definitions', () => {
|
|
53
|
+
it('should create all 11 tools', () => {
|
|
54
|
+
expect(Object.keys(tools)).toHaveLength(11);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should have description and inputSchema on every tool', () => {
|
|
58
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
59
|
+
expect(tool.description).toBeTruthy();
|
|
60
|
+
expect(tool.inputSchema).toBeDefined();
|
|
61
|
+
expect(typeof tool.execute).toBe('function');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('get_team_status', () => {
|
|
67
|
+
it('should return team data on success', async () => {
|
|
68
|
+
mockClient.get.mockResolvedValue({ success: true, status: 200, data: [{ id: 'team1' }] });
|
|
69
|
+
const result = await tools.get_team_status.execute({});
|
|
70
|
+
expect(mockClient.get).toHaveBeenCalledWith('/teams');
|
|
71
|
+
expect(result).toEqual([{ id: 'team1' }]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return error on failure', async () => {
|
|
75
|
+
mockClient.get.mockResolvedValue({ success: false, status: 500, error: 'Network error' });
|
|
76
|
+
const result = await tools.get_team_status.execute({});
|
|
77
|
+
expect(result).toEqual({ error: 'Network error' });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('get_agent_logs', () => {
|
|
82
|
+
it('should fetch logs with line count', async () => {
|
|
83
|
+
mockClient.get.mockResolvedValue({ success: true, status: 200, data: { output: 'log data' } });
|
|
84
|
+
const result = await tools.get_agent_logs.execute({ sessionName: 'agent-1', lines: 30 });
|
|
85
|
+
expect(mockClient.get).toHaveBeenCalledWith('/terminal/agent-1/output?lines=30');
|
|
86
|
+
expect(result).toEqual({ output: 'log data' });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('get_tasks', () => {
|
|
91
|
+
it('should fetch tasks with project path', async () => {
|
|
92
|
+
mockClient.get.mockResolvedValue({ success: true, status: 200, data: [{ id: 'task1' }] });
|
|
93
|
+
await tools.get_tasks.execute({ projectPath: '/proj', status: 'in_progress' });
|
|
94
|
+
// V3-only as of spec 2026-05-06-task-management-v1-deprecation.md.
|
|
95
|
+
expect(mockClient.get).toHaveBeenCalledWith(expect.stringContaining('/task-pool/items'));
|
|
96
|
+
expect(mockClient.get).toHaveBeenCalledWith(expect.stringContaining('status=in_progress'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should fetch all tasks when no status filter', async () => {
|
|
100
|
+
mockClient.get.mockResolvedValue({ success: true, status: 200, data: [] });
|
|
101
|
+
await tools.get_tasks.execute({ projectPath: '/proj' });
|
|
102
|
+
expect(mockClient.get).toHaveBeenCalledWith(expect.not.stringContaining('status='));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('recall_goals', () => {
|
|
107
|
+
it('should call memory recall with project scope', async () => {
|
|
108
|
+
mockClient.post.mockResolvedValue({ success: true, status: 200, data: { goals: ['OKR1'] } });
|
|
109
|
+
const result = await tools.recall_goals.execute({ context: 'current goals' });
|
|
110
|
+
expect(mockClient.post).toHaveBeenCalledWith('/memory/recall', {
|
|
111
|
+
context: 'current goals',
|
|
112
|
+
scope: 'project',
|
|
113
|
+
});
|
|
114
|
+
expect(result).toEqual({ goals: ['OKR1'] });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('heartbeat', () => {
|
|
119
|
+
it('should return combined teams and projects status', async () => {
|
|
120
|
+
mockClient.get.mockResolvedValueOnce({ success: true, status: 200, data: [{ id: 't1' }] });
|
|
121
|
+
mockClient.get.mockResolvedValueOnce({ success: true, status: 200, data: [{ id: 'p1' }] });
|
|
122
|
+
const result = await tools.heartbeat.execute({}) as Record<string, unknown>;
|
|
123
|
+
expect(result.teams).toEqual([{ id: 't1' }]);
|
|
124
|
+
expect(result.projects).toEqual([{ id: 'p1' }]);
|
|
125
|
+
expect(result.timestamp).toBeTruthy();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('get_agent_status', () => {
|
|
130
|
+
it('should find agent by session name across teams', async () => {
|
|
131
|
+
mockClient.get.mockResolvedValue({
|
|
132
|
+
success: true,
|
|
133
|
+
status: 200,
|
|
134
|
+
data: [
|
|
135
|
+
{ members: [{ sessionName: 'other', name: 'Other' }] },
|
|
136
|
+
{ members: [{ sessionName: 'target', name: 'Target', agentStatus: 'active' }] },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
const result = await tools.get_agent_status.execute({ sessionName: 'target' });
|
|
140
|
+
expect(result).toEqual({ sessionName: 'target', name: 'Target', agentStatus: 'active' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should return error when agent not found', async () => {
|
|
144
|
+
mockClient.get.mockResolvedValue({ success: true, status: 200, data: [{ members: [] }] });
|
|
145
|
+
const result = await tools.get_agent_status.execute({ sessionName: 'missing' }) as { error: string };
|
|
146
|
+
expect(result.error).toContain('not found');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('subscribe_event', () => {
|
|
151
|
+
it('should subscribe with auditor as target', async () => {
|
|
152
|
+
mockClient.post.mockResolvedValue({ success: true, status: 200, data: { subscriptionId: 's1' } });
|
|
153
|
+
await tools.subscribe_event.execute({
|
|
154
|
+
eventType: 'agent:idle',
|
|
155
|
+
oneShot: false,
|
|
156
|
+
});
|
|
157
|
+
expect(mockClient.post).toHaveBeenCalledWith('/events/subscribe', expect.objectContaining({
|
|
158
|
+
eventType: 'agent:idle',
|
|
159
|
+
target: 'crewly-auditor',
|
|
160
|
+
}));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('reply_slack', () => {
|
|
165
|
+
it('should send message via slack API', async () => {
|
|
166
|
+
mockClient.post.mockResolvedValue({ success: true, status: 200, data: { ok: true } });
|
|
167
|
+
const result = await tools.reply_slack.execute({
|
|
168
|
+
text: 'Here are the audit findings...',
|
|
169
|
+
channelId: 'C12345',
|
|
170
|
+
threadTs: '1234567890.123456',
|
|
171
|
+
}) as { sent: boolean };
|
|
172
|
+
expect(mockClient.post).toHaveBeenCalledWith('/slack/send', {
|
|
173
|
+
channelId: 'C12345',
|
|
174
|
+
text: 'Here are the audit findings...',
|
|
175
|
+
threadTs: '1234567890.123456',
|
|
176
|
+
});
|
|
177
|
+
expect(result.sent).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should return error on failure', async () => {
|
|
181
|
+
mockClient.post.mockResolvedValue({ success: false, status: 500, error: 'Slack API error' });
|
|
182
|
+
const result = await tools.reply_slack.execute({
|
|
183
|
+
text: 'test',
|
|
184
|
+
channelId: 'C1',
|
|
185
|
+
threadTs: 't1',
|
|
186
|
+
}) as { error: string };
|
|
187
|
+
expect(result.error).toBe('Slack API error');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('write_audit_report', () => {
|
|
192
|
+
it('should create bugs.md with header when file does not exist', async () => {
|
|
193
|
+
readFileSpy.mockRejectedValue(new Error('ENOENT') as never);
|
|
194
|
+
writeFileSpy.mockResolvedValue(undefined as never);
|
|
195
|
+
|
|
196
|
+
const result = await tools.write_audit_report.execute({
|
|
197
|
+
severity: 'high',
|
|
198
|
+
agents: ['agent-sam'],
|
|
199
|
+
title: 'Agent stuck in loop',
|
|
200
|
+
description: 'Agent Sam has been retrying the same operation for 30 minutes.',
|
|
201
|
+
evidence: 'Error: Build failed\nError: Build failed\nError: Build failed',
|
|
202
|
+
suggestion: 'Investigate build configuration or reset agent.',
|
|
203
|
+
}) as { success: boolean; severity: string; title: string };
|
|
204
|
+
|
|
205
|
+
expect(mkdirSpy).toHaveBeenCalledWith(
|
|
206
|
+
path.join(projectPath, '.crewly', 'audit'),
|
|
207
|
+
{ recursive: true },
|
|
208
|
+
);
|
|
209
|
+
expect(writeFileSpy).toHaveBeenCalled();
|
|
210
|
+
const written = (writeFileSpy.mock.calls[0] as any[])[1] as string;
|
|
211
|
+
expect(written).toContain('# Crewly Audit');
|
|
212
|
+
expect(written).toContain('[HIGH] Agent stuck in loop');
|
|
213
|
+
expect(written).toContain('agent-sam');
|
|
214
|
+
expect(written).toContain('Build failed');
|
|
215
|
+
expect(result.success).toBe(true);
|
|
216
|
+
expect(result.severity).toBe('high');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should append to existing bugs.md', async () => {
|
|
220
|
+
readFileSpy.mockResolvedValue('# Existing content\n\n---\n' as never);
|
|
221
|
+
writeFileSpy.mockResolvedValue(undefined as never);
|
|
222
|
+
|
|
223
|
+
await tools.write_audit_report.execute({
|
|
224
|
+
severity: 'medium',
|
|
225
|
+
agents: ['agent-leo'],
|
|
226
|
+
title: 'Idle agent with pending tasks',
|
|
227
|
+
description: 'Leo has been idle for 20 minutes with 2 open tasks.',
|
|
228
|
+
evidence: 'Status: idle. Open tasks: task-123, task-456',
|
|
229
|
+
suggestion: 'Send a nudge message to Leo.',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const written = (writeFileSpy.mock.calls[0] as any[])[1] as string;
|
|
233
|
+
expect(written).toContain('# Existing content');
|
|
234
|
+
expect(written).toContain('[MEDIUM] Idle agent');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should use correct severity icons', async () => {
|
|
238
|
+
readFileSpy.mockRejectedValue(new Error('ENOENT') as never);
|
|
239
|
+
writeFileSpy.mockResolvedValue(undefined as never);
|
|
240
|
+
|
|
241
|
+
for (const [sev, icon] of [['critical', '🔴'], ['high', '🟠'], ['medium', '🟡'], ['low', '🔵']] as const) {
|
|
242
|
+
await tools.write_audit_report.execute({
|
|
243
|
+
severity: sev,
|
|
244
|
+
agents: ['test'],
|
|
245
|
+
title: `${sev} issue`,
|
|
246
|
+
description: 'Test',
|
|
247
|
+
evidence: 'Test',
|
|
248
|
+
suggestion: 'Test',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const calls = writeFileSpy.mock.calls;
|
|
253
|
+
expect((calls[0] as any[])[1]).toContain('🔴');
|
|
254
|
+
expect((calls[1] as any[])[1]).toContain('🟠');
|
|
255
|
+
expect((calls[2] as any[])[1]).toContain('🟡');
|
|
256
|
+
expect((calls[3] as any[])[1]).toContain('🔵');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('read_audit_history', () => {
|
|
261
|
+
it('should return recent findings', async () => {
|
|
262
|
+
readFileSpy.mockResolvedValue('# Header\n---\nFinding 1\n---\nFinding 2\n---\nFinding 3' as never);
|
|
263
|
+
const result = await tools.read_audit_history.execute({ lastN: 2 }) as { totalFindings: number };
|
|
264
|
+
expect(result.totalFindings).toBe(3); // 4 sections - 1 header
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should return empty when no history', async () => {
|
|
268
|
+
readFileSpy.mockRejectedValue(new Error('ENOENT') as never);
|
|
269
|
+
const result = await tools.read_audit_history.execute({ lastN: 10 }) as { totalFindings: number; recentFindings: string };
|
|
270
|
+
expect(result.totalFindings).toBe(0);
|
|
271
|
+
expect(result.recentFindings).toContain('No audit history');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|