@auxiora/autonomy 1.0.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/LICENSE +191 -0
- package/dist/audit-trail.d.ts +21 -0
- package/dist/audit-trail.d.ts.map +1 -0
- package/dist/audit-trail.js +74 -0
- package/dist/audit-trail.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/rollback.d.ts +15 -0
- package/dist/rollback.d.ts.map +1 -0
- package/dist/rollback.js +30 -0
- package/dist/rollback.js.map +1 -0
- package/dist/trust-engine.d.ts +21 -0
- package/dist/trust-engine.d.ts.map +1 -0
- package/dist/trust-engine.js +186 -0
- package/dist/trust-engine.js.map +1 -0
- package/dist/trust-gate.d.ts +15 -0
- package/dist/trust-gate.d.ts.map +1 -0
- package/dist/trust-gate.js +22 -0
- package/dist/trust-gate.js.map +1 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +25 -0
- package/src/audit-trail.ts +93 -0
- package/src/index.ts +19 -0
- package/src/rollback.ts +44 -0
- package/src/trust-engine.ts +218 -0
- package/src/trust-gate.ts +36 -0
- package/src/types.ts +105 -0
- package/tests/audit-trail.test.ts +167 -0
- package/tests/rollback.test.ts +135 -0
- package/tests/trust-engine.test.ts +182 -0
- package/tests/trust-gate.test.ts +54 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { ActionAuditTrail } from '../src/audit-trail.js';
|
|
6
|
+
|
|
7
|
+
describe('ActionAuditTrail', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let filePath: string;
|
|
10
|
+
let trail: ActionAuditTrail;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'audit-trail-'));
|
|
14
|
+
filePath = path.join(tmpDir, 'audit.json');
|
|
15
|
+
trail = new ActionAuditTrail(filePath);
|
|
16
|
+
await trail.load();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should record an audit entry', async () => {
|
|
24
|
+
const entry = await trail.record({
|
|
25
|
+
trustLevel: 2,
|
|
26
|
+
domain: 'messaging',
|
|
27
|
+
intent: 'Send message',
|
|
28
|
+
plan: 'Send via Slack',
|
|
29
|
+
executed: true,
|
|
30
|
+
outcome: 'success',
|
|
31
|
+
reasoning: 'User requested message send',
|
|
32
|
+
rollbackAvailable: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(entry.id).toBeTruthy();
|
|
36
|
+
expect(entry.timestamp).toBeGreaterThan(0);
|
|
37
|
+
expect(entry.domain).toBe('messaging');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should retrieve by id', async () => {
|
|
41
|
+
const entry = await trail.record({
|
|
42
|
+
trustLevel: 1,
|
|
43
|
+
domain: 'web',
|
|
44
|
+
intent: 'Browse',
|
|
45
|
+
plan: 'Open URL',
|
|
46
|
+
executed: true,
|
|
47
|
+
outcome: 'success',
|
|
48
|
+
reasoning: 'Test',
|
|
49
|
+
rollbackAvailable: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const found = trail.getById(entry.id);
|
|
53
|
+
expect(found).toEqual(entry);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return undefined for unknown id', () => {
|
|
57
|
+
expect(trail.getById('nonexistent')).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should query by domain', async () => {
|
|
61
|
+
await trail.record({
|
|
62
|
+
trustLevel: 1,
|
|
63
|
+
domain: 'web',
|
|
64
|
+
intent: 'Browse',
|
|
65
|
+
plan: 'Open URL',
|
|
66
|
+
executed: true,
|
|
67
|
+
outcome: 'success',
|
|
68
|
+
reasoning: 'Test',
|
|
69
|
+
rollbackAvailable: false,
|
|
70
|
+
});
|
|
71
|
+
await trail.record({
|
|
72
|
+
trustLevel: 2,
|
|
73
|
+
domain: 'files',
|
|
74
|
+
intent: 'Write file',
|
|
75
|
+
plan: 'Create file',
|
|
76
|
+
executed: true,
|
|
77
|
+
outcome: 'success',
|
|
78
|
+
reasoning: 'Test',
|
|
79
|
+
rollbackAvailable: false,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const webEntries = trail.query({ domain: 'web' });
|
|
83
|
+
expect(webEntries).toHaveLength(1);
|
|
84
|
+
expect(webEntries[0].domain).toBe('web');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should query by outcome', async () => {
|
|
88
|
+
await trail.record({
|
|
89
|
+
trustLevel: 1,
|
|
90
|
+
domain: 'web',
|
|
91
|
+
intent: 'Browse',
|
|
92
|
+
plan: 'Open URL',
|
|
93
|
+
executed: true,
|
|
94
|
+
outcome: 'success',
|
|
95
|
+
reasoning: 'Test',
|
|
96
|
+
rollbackAvailable: false,
|
|
97
|
+
});
|
|
98
|
+
await trail.record({
|
|
99
|
+
trustLevel: 1,
|
|
100
|
+
domain: 'web',
|
|
101
|
+
intent: 'Browse',
|
|
102
|
+
plan: 'Open URL',
|
|
103
|
+
executed: false,
|
|
104
|
+
outcome: 'failure',
|
|
105
|
+
reasoning: 'Blocked',
|
|
106
|
+
rollbackAvailable: false,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const failures = trail.query({ outcome: 'failure' });
|
|
110
|
+
expect(failures).toHaveLength(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should query with limit', async () => {
|
|
114
|
+
for (let i = 0; i < 5; i++) {
|
|
115
|
+
await trail.record({
|
|
116
|
+
trustLevel: 1,
|
|
117
|
+
domain: 'web',
|
|
118
|
+
intent: `Action ${i}`,
|
|
119
|
+
plan: 'Plan',
|
|
120
|
+
executed: true,
|
|
121
|
+
outcome: 'success',
|
|
122
|
+
reasoning: 'Test',
|
|
123
|
+
rollbackAvailable: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const limited = trail.query({ limit: 3 });
|
|
128
|
+
expect(limited).toHaveLength(3);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should mark as rolled back', async () => {
|
|
132
|
+
const entry = await trail.record({
|
|
133
|
+
trustLevel: 2,
|
|
134
|
+
domain: 'files',
|
|
135
|
+
intent: 'Delete file',
|
|
136
|
+
plan: 'Remove /tmp/test',
|
|
137
|
+
executed: true,
|
|
138
|
+
outcome: 'success',
|
|
139
|
+
reasoning: 'Cleanup',
|
|
140
|
+
rollbackAvailable: true,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const result = await trail.markRolledBack(entry.id);
|
|
144
|
+
expect(result).toBe(true);
|
|
145
|
+
|
|
146
|
+
const updated = trail.getById(entry.id);
|
|
147
|
+
expect(updated?.outcome).toBe('rolled_back');
|
|
148
|
+
expect(updated?.rollbackAvailable).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should persist and reload entries', async () => {
|
|
152
|
+
await trail.record({
|
|
153
|
+
trustLevel: 1,
|
|
154
|
+
domain: 'web',
|
|
155
|
+
intent: 'Browse',
|
|
156
|
+
plan: 'Open URL',
|
|
157
|
+
executed: true,
|
|
158
|
+
outcome: 'success',
|
|
159
|
+
reasoning: 'Test',
|
|
160
|
+
rollbackAvailable: false,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const trail2 = new ActionAuditTrail(filePath);
|
|
164
|
+
await trail2.load();
|
|
165
|
+
expect(trail2.getAll()).toHaveLength(1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { ActionAuditTrail } from '../src/audit-trail.js';
|
|
6
|
+
import { RollbackManager } from '../src/rollback.js';
|
|
7
|
+
|
|
8
|
+
describe('RollbackManager', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let trail: ActionAuditTrail;
|
|
11
|
+
let rollback: RollbackManager;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rollback-'));
|
|
15
|
+
trail = new ActionAuditTrail(path.join(tmpDir, 'audit.json'));
|
|
16
|
+
await trail.load();
|
|
17
|
+
rollback = new RollbackManager(trail);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should report rollback availability', async () => {
|
|
25
|
+
const entry = await trail.record({
|
|
26
|
+
trustLevel: 2,
|
|
27
|
+
domain: 'files',
|
|
28
|
+
intent: 'Delete file',
|
|
29
|
+
plan: 'rm /tmp/test',
|
|
30
|
+
executed: true,
|
|
31
|
+
outcome: 'success',
|
|
32
|
+
reasoning: 'User requested',
|
|
33
|
+
rollbackAvailable: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(rollback.canRollback(entry.id)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return false for non-rollbackable actions', async () => {
|
|
40
|
+
const entry = await trail.record({
|
|
41
|
+
trustLevel: 1,
|
|
42
|
+
domain: 'messaging',
|
|
43
|
+
intent: 'Send message',
|
|
44
|
+
plan: 'Send via Slack',
|
|
45
|
+
executed: true,
|
|
46
|
+
outcome: 'success',
|
|
47
|
+
reasoning: 'Sent',
|
|
48
|
+
rollbackAvailable: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(rollback.canRollback(entry.id)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return false for unknown audit id', () => {
|
|
55
|
+
expect(rollback.canRollback('nonexistent')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should perform rollback', async () => {
|
|
59
|
+
const entry = await trail.record({
|
|
60
|
+
trustLevel: 2,
|
|
61
|
+
domain: 'files',
|
|
62
|
+
intent: 'Create file',
|
|
63
|
+
plan: 'Write to /tmp/test',
|
|
64
|
+
executed: true,
|
|
65
|
+
outcome: 'success',
|
|
66
|
+
reasoning: 'User requested',
|
|
67
|
+
rollbackAvailable: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = await rollback.rollback(entry.id);
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
|
|
73
|
+
// Should not be rollbackable anymore
|
|
74
|
+
expect(rollback.canRollback(entry.id)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should fail rollback for non-existent entry', async () => {
|
|
78
|
+
const result = await rollback.rollback('nonexistent');
|
|
79
|
+
expect(result.success).toBe(false);
|
|
80
|
+
expect(result.error).toBe('Audit entry not found');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should fail rollback for non-rollbackable entry', async () => {
|
|
84
|
+
const entry = await trail.record({
|
|
85
|
+
trustLevel: 1,
|
|
86
|
+
domain: 'messaging',
|
|
87
|
+
intent: 'Send message',
|
|
88
|
+
plan: 'Send',
|
|
89
|
+
executed: true,
|
|
90
|
+
outcome: 'success',
|
|
91
|
+
reasoning: 'Sent',
|
|
92
|
+
rollbackAvailable: false,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = await rollback.rollback(entry.id);
|
|
96
|
+
expect(result.success).toBe(false);
|
|
97
|
+
expect(result.error).toBe('Rollback not available for this action');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should fail double rollback', async () => {
|
|
101
|
+
const entry = await trail.record({
|
|
102
|
+
trustLevel: 2,
|
|
103
|
+
domain: 'files',
|
|
104
|
+
intent: 'Create file',
|
|
105
|
+
plan: 'Write',
|
|
106
|
+
executed: true,
|
|
107
|
+
outcome: 'success',
|
|
108
|
+
reasoning: 'Test',
|
|
109
|
+
rollbackAvailable: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await rollback.rollback(entry.id);
|
|
113
|
+
const result = await rollback.rollback(entry.id);
|
|
114
|
+
expect(result.success).toBe(false);
|
|
115
|
+
expect(result.error).toBe('Action already rolled back');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should return rollback history', async () => {
|
|
119
|
+
const entry = await trail.record({
|
|
120
|
+
trustLevel: 2,
|
|
121
|
+
domain: 'files',
|
|
122
|
+
intent: 'Create file',
|
|
123
|
+
plan: 'Write',
|
|
124
|
+
executed: true,
|
|
125
|
+
outcome: 'success',
|
|
126
|
+
reasoning: 'Test',
|
|
127
|
+
rollbackAvailable: true,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await rollback.rollback(entry.id);
|
|
131
|
+
const history = rollback.getHistory();
|
|
132
|
+
expect(history).toHaveLength(1);
|
|
133
|
+
expect(history[0].outcome).toBe('rolled_back');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { TrustEngine } from '../src/trust-engine.js';
|
|
6
|
+
import type { TrustLevel } from '../src/types.js';
|
|
7
|
+
|
|
8
|
+
describe('TrustEngine', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let statePath: string;
|
|
11
|
+
let engine: TrustEngine;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trust-engine-'));
|
|
15
|
+
statePath = path.join(tmpDir, 'trust-state.json');
|
|
16
|
+
engine = new TrustEngine({ defaultLevel: 0 }, statePath);
|
|
17
|
+
await engine.load();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should start with default trust level', () => {
|
|
25
|
+
expect(engine.getTrustLevel('messaging')).toBe(0);
|
|
26
|
+
expect(engine.getTrustLevel('files')).toBe(0);
|
|
27
|
+
expect(engine.getTrustLevel('shell')).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return all levels', () => {
|
|
31
|
+
const levels = engine.getAllLevels();
|
|
32
|
+
expect(levels.messaging).toBe(0);
|
|
33
|
+
expect(levels.files).toBe(0);
|
|
34
|
+
expect(Object.keys(levels).length).toBeGreaterThanOrEqual(9);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should set trust level manually', async () => {
|
|
38
|
+
await engine.setTrustLevel('messaging', 3, 'User approved');
|
|
39
|
+
expect(engine.getTrustLevel('messaging')).toBe(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should record a promotion when level increases', async () => {
|
|
43
|
+
await engine.setTrustLevel('web', 2, 'Good behavior');
|
|
44
|
+
const promotions = engine.getPromotions();
|
|
45
|
+
expect(promotions).toHaveLength(1);
|
|
46
|
+
expect(promotions[0].domain).toBe('web');
|
|
47
|
+
expect(promotions[0].fromLevel).toBe(0);
|
|
48
|
+
expect(promotions[0].toLevel).toBe(2);
|
|
49
|
+
expect(promotions[0].automatic).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should record a demotion when level decreases', async () => {
|
|
53
|
+
await engine.setTrustLevel('shell', 3, 'Initial');
|
|
54
|
+
await engine.setTrustLevel('shell', 1, 'Bad behavior');
|
|
55
|
+
const demotions = engine.getDemotions();
|
|
56
|
+
expect(demotions).toHaveLength(1);
|
|
57
|
+
expect(demotions[0].fromLevel).toBe(3);
|
|
58
|
+
expect(demotions[0].toLevel).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not change when setting same level', async () => {
|
|
62
|
+
await engine.setTrustLevel('messaging', 0, 'No change');
|
|
63
|
+
expect(engine.getPromotions()).toHaveLength(0);
|
|
64
|
+
expect(engine.getDemotions()).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should check permission correctly', async () => {
|
|
68
|
+
await engine.setTrustLevel('files', 2, 'Set level');
|
|
69
|
+
expect(engine.checkPermission('files', 2)).toBe(true);
|
|
70
|
+
expect(engine.checkPermission('files', 1)).toBe(true);
|
|
71
|
+
expect(engine.checkPermission('files', 3)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should auto-demote after consecutive failures', async () => {
|
|
75
|
+
await engine.setTrustLevel('web', 2, 'Initial');
|
|
76
|
+
|
|
77
|
+
// Record failures up to demotion threshold (default 3)
|
|
78
|
+
await engine.recordOutcome('web', false);
|
|
79
|
+
await engine.recordOutcome('web', false);
|
|
80
|
+
const result = await engine.recordOutcome('web', false);
|
|
81
|
+
|
|
82
|
+
expect(result).not.toBeNull();
|
|
83
|
+
expect(engine.getTrustLevel('web')).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reset failure count on success', async () => {
|
|
87
|
+
await engine.setTrustLevel('web', 2, 'Initial');
|
|
88
|
+
|
|
89
|
+
await engine.recordOutcome('web', false);
|
|
90
|
+
await engine.recordOutcome('web', false);
|
|
91
|
+
await engine.recordOutcome('web', true); // Resets failures
|
|
92
|
+
|
|
93
|
+
await engine.recordOutcome('web', false);
|
|
94
|
+
await engine.recordOutcome('web', false);
|
|
95
|
+
|
|
96
|
+
// Should still be at level 2 since failures were reset
|
|
97
|
+
expect(engine.getTrustLevel('web')).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should auto-promote after enough successes', async () => {
|
|
101
|
+
engine = new TrustEngine(
|
|
102
|
+
{ defaultLevel: 0, autoPromote: true, promotionThreshold: 3, autoPromoteCeiling: 3 },
|
|
103
|
+
statePath,
|
|
104
|
+
);
|
|
105
|
+
await engine.load();
|
|
106
|
+
|
|
107
|
+
await engine.recordOutcome('messaging', true);
|
|
108
|
+
await engine.recordOutcome('messaging', true);
|
|
109
|
+
const result = await engine.recordOutcome('messaging', true);
|
|
110
|
+
|
|
111
|
+
expect(result).not.toBeNull();
|
|
112
|
+
expect(engine.getTrustLevel('messaging')).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should not auto-promote above ceiling', async () => {
|
|
116
|
+
engine = new TrustEngine(
|
|
117
|
+
{ defaultLevel: 0, autoPromote: true, promotionThreshold: 1, autoPromoteCeiling: 2 },
|
|
118
|
+
statePath,
|
|
119
|
+
);
|
|
120
|
+
await engine.load();
|
|
121
|
+
|
|
122
|
+
// Promote 0 -> 1
|
|
123
|
+
await engine.recordOutcome('messaging', true);
|
|
124
|
+
expect(engine.getTrustLevel('messaging')).toBe(1);
|
|
125
|
+
|
|
126
|
+
// Promote 1 -> 2
|
|
127
|
+
await engine.recordOutcome('messaging', true);
|
|
128
|
+
expect(engine.getTrustLevel('messaging')).toBe(2);
|
|
129
|
+
|
|
130
|
+
// Should NOT promote above ceiling
|
|
131
|
+
await engine.recordOutcome('messaging', true);
|
|
132
|
+
expect(engine.getTrustLevel('messaging')).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should not auto-promote when disabled', async () => {
|
|
136
|
+
engine = new TrustEngine(
|
|
137
|
+
{ defaultLevel: 0, autoPromote: false, promotionThreshold: 1 },
|
|
138
|
+
statePath,
|
|
139
|
+
);
|
|
140
|
+
await engine.load();
|
|
141
|
+
|
|
142
|
+
await engine.recordOutcome('messaging', true);
|
|
143
|
+
await engine.recordOutcome('messaging', true);
|
|
144
|
+
expect(engine.getTrustLevel('messaging')).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should persist and reload state', async () => {
|
|
148
|
+
await engine.setTrustLevel('shell', 3, 'Set level');
|
|
149
|
+
await engine.save();
|
|
150
|
+
|
|
151
|
+
const engine2 = new TrustEngine({}, statePath);
|
|
152
|
+
await engine2.load();
|
|
153
|
+
|
|
154
|
+
expect(engine2.getTrustLevel('shell')).toBe(3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should demote explicitly', async () => {
|
|
158
|
+
await engine.setTrustLevel('finance', 3, 'Initial');
|
|
159
|
+
const result = await engine.demote('finance', 'User requested');
|
|
160
|
+
|
|
161
|
+
expect(result).not.toBeNull();
|
|
162
|
+
expect(result!.fromLevel).toBe(3);
|
|
163
|
+
expect(result!.toLevel).toBe(2);
|
|
164
|
+
expect(engine.getTrustLevel('finance')).toBe(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should return null when demoting from level 0', async () => {
|
|
168
|
+
const result = await engine.demote('messaging', 'Already at zero');
|
|
169
|
+
expect(result).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should return evidence', async () => {
|
|
173
|
+
await engine.recordOutcome('web', true);
|
|
174
|
+
await engine.recordOutcome('web', true);
|
|
175
|
+
await engine.recordOutcome('web', false);
|
|
176
|
+
|
|
177
|
+
const ev = engine.getEvidence('web');
|
|
178
|
+
expect(ev.successes).toBe(2); // Not reset on failure
|
|
179
|
+
expect(ev.failures).toBe(1);
|
|
180
|
+
expect(ev.lastActionAt).toBeGreaterThan(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { TrustEngine } from '../src/trust-engine.js';
|
|
6
|
+
import { TrustGate } from '../src/trust-gate.js';
|
|
7
|
+
|
|
8
|
+
describe('TrustGate', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let engine: TrustEngine;
|
|
11
|
+
let gate: TrustGate;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trust-gate-'));
|
|
15
|
+
engine = new TrustEngine({ defaultLevel: 0 }, path.join(tmpDir, 'state.json'));
|
|
16
|
+
await engine.load();
|
|
17
|
+
gate = new TrustGate(engine);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should deny action when trust level is insufficient', () => {
|
|
25
|
+
const result = gate.gate('shell', 'run command', 2);
|
|
26
|
+
expect(result.allowed).toBe(false);
|
|
27
|
+
expect(result.currentLevel).toBe(0);
|
|
28
|
+
expect(result.requiredLevel).toBe(2);
|
|
29
|
+
expect(result.message).toContain('denied');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should allow action when trust level is sufficient', async () => {
|
|
33
|
+
await engine.setTrustLevel('shell', 3, 'Approved');
|
|
34
|
+
const result = gate.gate('shell', 'run command', 2);
|
|
35
|
+
expect(result.allowed).toBe(true);
|
|
36
|
+
expect(result.message).toContain('allowed');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should allow action when trust level exactly matches', async () => {
|
|
40
|
+
await engine.setTrustLevel('files', 2, 'Set');
|
|
41
|
+
const result = gate.gate('files', 'write file', 2);
|
|
42
|
+
expect(result.allowed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should allow level 0 actions for all', () => {
|
|
46
|
+
const result = gate.gate('messaging', 'view messages', 0);
|
|
47
|
+
expect(result.allowed).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return correct domain in result', () => {
|
|
51
|
+
const result = gate.gate('finance', 'transfer', 4);
|
|
52
|
+
expect(result.domain).toBe('finance');
|
|
53
|
+
});
|
|
54
|
+
});
|