discord-agent-bridge 0.1.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 +21 -0
- package/README.md +195 -0
- package/dist/app.js +210 -0
- package/dist/app.js.map +1 -0
- package/dist/app.test.js +117 -0
- package/dist/app.test.js.map +1 -0
- package/dist/cli.js +63 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.js +91 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/core/auditLog.js +65 -0
- package/dist/core/auditLog.js.map +1 -0
- package/dist/core/auditLog.test.js +124 -0
- package/dist/core/auditLog.test.js.map +1 -0
- package/dist/core/auth.js +106 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/auth.test.js +216 -0
- package/dist/core/auth.test.js.map +1 -0
- package/dist/core/channelRegistry.js +119 -0
- package/dist/core/channelRegistry.js.map +1 -0
- package/dist/core/channelRegistry.test.js +114 -0
- package/dist/core/channelRegistry.test.js.map +1 -0
- package/dist/core/commandPolicy.js +6 -0
- package/dist/core/commandPolicy.js.map +1 -0
- package/dist/core/commandRouter.js +9 -0
- package/dist/core/commandRouter.js.map +1 -0
- package/dist/core/config.js +141 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.js +149 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/configResolver.js +90 -0
- package/dist/core/configResolver.js.map +1 -0
- package/dist/core/configResolver.test.js +138 -0
- package/dist/core/configResolver.test.js.map +1 -0
- package/dist/core/configSchema.js +164 -0
- package/dist/core/configSchema.js.map +1 -0
- package/dist/core/contracts.js +5 -0
- package/dist/core/contracts.js.map +1 -0
- package/dist/core/eventBus.js +42 -0
- package/dist/core/eventBus.js.map +1 -0
- package/dist/core/eventBus.test.js +72 -0
- package/dist/core/eventBus.test.js.map +1 -0
- package/dist/core/hookBridge.js +11 -0
- package/dist/core/hookBridge.js.map +1 -0
- package/dist/core/logger.js +116 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/logger.test.js +88 -0
- package/dist/core/logger.test.js.map +1 -0
- package/dist/core/modeRegistry.js +33 -0
- package/dist/core/modeRegistry.js.map +1 -0
- package/dist/core/permissionResolver.js +43 -0
- package/dist/core/permissionResolver.js.map +1 -0
- package/dist/core/permissionResolver.test.js +136 -0
- package/dist/core/permissionResolver.test.js.map +1 -0
- package/dist/core/providerCatalog.js +240 -0
- package/dist/core/providerCatalog.js.map +1 -0
- package/dist/core/providerCatalog.test.js +230 -0
- package/dist/core/providerCatalog.test.js.map +1 -0
- package/dist/core/sessionOrchestrator.js +463 -0
- package/dist/core/sessionOrchestrator.js.map +1 -0
- package/dist/core/sessionOrchestrator.test.js +344 -0
- package/dist/core/sessionOrchestrator.test.js.map +1 -0
- package/dist/core/state/schema.js +49 -0
- package/dist/core/state/schema.js.map +1 -0
- package/dist/core/state/store.js +86 -0
- package/dist/core/state/store.js.map +1 -0
- package/dist/core/state/store.test.js +99 -0
- package/dist/core/state/store.test.js.map +1 -0
- package/dist/core/usageService.js +310 -0
- package/dist/core/usageService.js.map +1 -0
- package/dist/core/usageService.test.js +234 -0
- package/dist/core/usageService.test.js.map +1 -0
- package/dist/discord/alwaysAllow.test.js +181 -0
- package/dist/discord/alwaysAllow.test.js.map +1 -0
- package/dist/discord/client.js +666 -0
- package/dist/discord/client.js.map +1 -0
- package/dist/discord/client.test.js +256 -0
- package/dist/discord/client.test.js.map +1 -0
- package/dist/discord/configPanel.js +270 -0
- package/dist/discord/configPanel.js.map +1 -0
- package/dist/discord/configPanel.test.js +332 -0
- package/dist/discord/configPanel.test.js.map +1 -0
- package/dist/discord/directoryBrowser.js +151 -0
- package/dist/discord/directoryBrowser.js.map +1 -0
- package/dist/discord/directoryBrowser.test.js +163 -0
- package/dist/discord/directoryBrowser.test.js.map +1 -0
- package/dist/discord/favorites.js +10 -0
- package/dist/discord/favorites.js.map +1 -0
- package/dist/discord/fileDownload.js +94 -0
- package/dist/discord/fileDownload.js.map +1 -0
- package/dist/discord/fileDownload.test.js +59 -0
- package/dist/discord/fileDownload.test.js.map +1 -0
- package/dist/discord/format.js +104 -0
- package/dist/discord/format.js.map +1 -0
- package/dist/discord/format.test.js +40 -0
- package/dist/discord/format.test.js.map +1 -0
- package/dist/discord/guildChannels.js +97 -0
- package/dist/discord/guildChannels.js.map +1 -0
- package/dist/discord/guildChannels.test.js +231 -0
- package/dist/discord/guildChannels.test.js.map +1 -0
- package/dist/discord/i18n.js +207 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/i18n.test.js +31 -0
- package/dist/discord/i18n.test.js.map +1 -0
- package/dist/discord/interactionRouter.js +954 -0
- package/dist/discord/interactionRouter.js.map +1 -0
- package/dist/discord/interactionRouter.test.js +1245 -0
- package/dist/discord/interactionRouter.test.js.map +1 -0
- package/dist/discord/messageRouter.js +221 -0
- package/dist/discord/messageRouter.js.map +1 -0
- package/dist/discord/messageRouter.test.js +360 -0
- package/dist/discord/messageRouter.test.js.map +1 -0
- package/dist/discord/ports.js +12 -0
- package/dist/discord/ports.js.map +1 -0
- package/dist/discord/renderers/defaultSet.test.js +97 -0
- package/dist/discord/renderers/defaultSet.test.js.map +1 -0
- package/dist/discord/renderers/diffView.js +73 -0
- package/dist/discord/renderers/diffView.js.map +1 -0
- package/dist/discord/renderers/diffView.test.js +54 -0
- package/dist/discord/renderers/diffView.test.js.map +1 -0
- package/dist/discord/renderers/index.js +143 -0
- package/dist/discord/renderers/index.js.map +1 -0
- package/dist/discord/renderers/index.test.js +124 -0
- package/dist/discord/renderers/index.test.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.js +19 -0
- package/dist/discord/renderers/mentionOnComplete.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.test.js +35 -0
- package/dist/discord/renderers/mentionOnComplete.test.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.js +150 -0
- package/dist/discord/renderers/permissionButtons.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.test.js +108 -0
- package/dist/discord/renderers/permissionButtons.test.js.map +1 -0
- package/dist/discord/renderers/resultLine.js +25 -0
- package/dist/discord/renderers/resultLine.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.js +20 -0
- package/dist/discord/renderers/statusEmbed.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.test.js +32 -0
- package/dist/discord/renderers/statusEmbed.test.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.js +134 -0
- package/dist/discord/renderers/streamEmbed.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.test.js +97 -0
- package/dist/discord/renderers/streamEmbed.test.js.map +1 -0
- package/dist/discord/renderers/toolThread.js +65 -0
- package/dist/discord/renderers/toolThread.js.map +1 -0
- package/dist/discord/renderers/toolThread.test.js +54 -0
- package/dist/discord/renderers/toolThread.test.js.map +1 -0
- package/dist/discord/renderers/transcriptFeed.js +33 -0
- package/dist/discord/renderers/transcriptFeed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.js +78 -0
- package/dist/discord/renderers/usageEmbed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.test.js +42 -0
- package/dist/discord/renderers/usageEmbed.test.js.map +1 -0
- package/dist/discord/wiring.js +214 -0
- package/dist/discord/wiring.js.map +1 -0
- package/dist/discord/wiring.test.js +252 -0
- package/dist/discord/wiring.test.js.map +1 -0
- package/dist/discord/wizard/channelWizard.js +305 -0
- package/dist/discord/wizard/channelWizard.js.map +1 -0
- package/dist/discord/wizard/channelWizard.test.js +298 -0
- package/dist/discord/wizard/channelWizard.test.js.map +1 -0
- package/dist/discord/wizard/resumeWizard.js +123 -0
- package/dist/discord/wizard/resumeWizard.js.map +1 -0
- package/dist/modes/claude/index.js +80 -0
- package/dist/modes/claude/index.js.map +1 -0
- package/dist/modes/claude/listResumable.test.js +72 -0
- package/dist/modes/claude/listResumable.test.js.map +1 -0
- package/dist/modes/claude/mcpFileTool.js +82 -0
- package/dist/modes/claude/mcpFileTool.js.map +1 -0
- package/dist/modes/claude/permissions.js +35 -0
- package/dist/modes/claude/permissions.js.map +1 -0
- package/dist/modes/claude/plugins.js +56 -0
- package/dist/modes/claude/plugins.js.map +1 -0
- package/dist/modes/claude/session.js +324 -0
- package/dist/modes/claude/session.js.map +1 -0
- package/dist/modes/claude/session.test.js +415 -0
- package/dist/modes/claude/session.test.js.map +1 -0
- package/dist/modes/codex/discovery.js +221 -0
- package/dist/modes/codex/discovery.js.map +1 -0
- package/dist/modes/codex/discovery.test.js +213 -0
- package/dist/modes/codex/discovery.test.js.map +1 -0
- package/dist/modes/codex/eventMapper.js +262 -0
- package/dist/modes/codex/eventMapper.js.map +1 -0
- package/dist/modes/codex/index.js +132 -0
- package/dist/modes/codex/index.js.map +1 -0
- package/dist/modes/codex/index.test.js +170 -0
- package/dist/modes/codex/index.test.js.map +1 -0
- package/dist/modes/codex/runner.js +207 -0
- package/dist/modes/codex/runner.js.map +1 -0
- package/dist/modes/codex/runner.test.js +587 -0
- package/dist/modes/codex/runner.test.js.map +1 -0
- package/dist/modes/codex/sqliteReader.js +171 -0
- package/dist/modes/codex/sqliteReader.js.map +1 -0
- package/dist/modes/codex/sqliteReader.test.js +137 -0
- package/dist/modes/codex/sqliteReader.test.js.map +1 -0
- package/dist/setup/wizard.js +115 -0
- package/dist/setup/wizard.js.map +1 -0
- package/dist/setup/wizard.test.js +216 -0
- package/dist/setup/wizard.test.js.map +1 -0
- package/dist/startBot.test.js +78 -0
- package/dist/startBot.test.js.map +1 -0
- package/package.json +58 -0
package/dist/cli.test.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// Mock the three modules cli.ts dispatches to so no real bot boots, no real wizard
|
|
3
|
+
// runs, and no real config file is touched. Spies are declared via vi.hoisted so
|
|
4
|
+
// they exist before the (hoisted) vi.mock factories reference them.
|
|
5
|
+
//
|
|
6
|
+
// `exists`/`tokenPresent` drive the mocked ConfigStore's needsSetup inputs: exists()
|
|
7
|
+
// returns `exists`, and load() returns a config whose token is empty (needs setup)
|
|
8
|
+
// or non-empty (already configured) based on `tokenPresent`. A load() that should
|
|
9
|
+
// throw (invalid config) is exercised where noted.
|
|
10
|
+
const { startBot, runSetup, exists, load } = vi.hoisted(() => ({
|
|
11
|
+
startBot: vi.fn(async () => ({})),
|
|
12
|
+
runSetup: vi.fn(async () => { }),
|
|
13
|
+
exists: vi.fn(() => true),
|
|
14
|
+
load: vi.fn(() => ({ discord: { token: 'present', clientId: 'client-id-000' } })),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('./app.js', () => ({ startBot }));
|
|
17
|
+
vi.mock('./setup/wizard.js', () => ({ runSetup }));
|
|
18
|
+
vi.mock('./core/config.js', () => ({
|
|
19
|
+
ConfigStore: class {
|
|
20
|
+
exists = exists;
|
|
21
|
+
load = load;
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
import { run, readVersion } from './cli.js';
|
|
25
|
+
describe('cli.run — argv dispatch', () => {
|
|
26
|
+
let logSpy;
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
startBot.mockClear();
|
|
29
|
+
runSetup.mockClear();
|
|
30
|
+
exists.mockReset();
|
|
31
|
+
load.mockReset();
|
|
32
|
+
// Default: configured (config present, token present) so the no-flag path starts
|
|
33
|
+
// directly unless a test opts into the first-run condition below.
|
|
34
|
+
exists.mockReturnValue(true);
|
|
35
|
+
load.mockReturnValue({ discord: { token: 'present', clientId: 'client-id-000' } });
|
|
36
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
logSpy.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
it('--version prints the package version and boots nothing', async () => {
|
|
42
|
+
await run(['--version']);
|
|
43
|
+
expect(logSpy).toHaveBeenCalledWith(readVersion());
|
|
44
|
+
// readVersion() reads the real package.json — assert it looks like a semver.
|
|
45
|
+
expect(readVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
|
46
|
+
expect(startBot).not.toHaveBeenCalled();
|
|
47
|
+
expect(runSetup).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
it('--setup calls runSetup only and boots nothing', async () => {
|
|
50
|
+
await run(['--setup']);
|
|
51
|
+
expect(runSetup).toHaveBeenCalledTimes(1);
|
|
52
|
+
expect(startBot).not.toHaveBeenCalled();
|
|
53
|
+
// --setup must not even probe config presence: it is an explicit re-configure.
|
|
54
|
+
expect(exists).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it('no flag + no config → runs setup THEN starts the bot (in that order)', async () => {
|
|
57
|
+
exists.mockReturnValue(false); // no config.json → needs setup
|
|
58
|
+
await run([]);
|
|
59
|
+
expect(runSetup).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(startBot).toHaveBeenCalledTimes(1);
|
|
61
|
+
// Order: setup must complete before the bot starts.
|
|
62
|
+
expect(runSetup.mock.invocationCallOrder[0]).toBeLessThan(startBot.mock.invocationCallOrder[0]);
|
|
63
|
+
// load() is never consulted when the file does not exist.
|
|
64
|
+
expect(load).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it('no flag + config present with empty token → runs setup THEN starts the bot', async () => {
|
|
67
|
+
exists.mockReturnValue(true);
|
|
68
|
+
load.mockReturnValue({ discord: { token: ' ', clientId: 'client-id-000' } });
|
|
69
|
+
await run([]);
|
|
70
|
+
expect(runSetup).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(startBot).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(runSetup.mock.invocationCallOrder[0]).toBeLessThan(startBot.mock.invocationCallOrder[0]);
|
|
73
|
+
});
|
|
74
|
+
it('no flag + config present and valid → starts the bot only (setup NOT called)', async () => {
|
|
75
|
+
// Defaults from beforeEach: exists()=true, token present.
|
|
76
|
+
await run([]);
|
|
77
|
+
expect(startBot).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(runSetup).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
it('no flag + present-but-invalid config → error propagates (no auto-setup swallow)', async () => {
|
|
81
|
+
exists.mockReturnValue(true);
|
|
82
|
+
load.mockImplementation(() => {
|
|
83
|
+
throw new Error('invalid config');
|
|
84
|
+
});
|
|
85
|
+
await expect(run([])).rejects.toThrow('invalid config');
|
|
86
|
+
// A real config bug must not trigger the wizard or a bot start.
|
|
87
|
+
expect(runSetup).not.toHaveBeenCalled();
|
|
88
|
+
expect(startBot).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
//# sourceMappingURL=cli.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.test.js","sourceRoot":"","sources":["../src/cli.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { redact } from './logger.js';
|
|
5
|
+
// Append-only who/when/what audit trail (§7.5). Each record() appends ONE JSON
|
|
6
|
+
// line to audit/audit.jsonl under the DAB home dir; it never rewrites the file,
|
|
7
|
+
// so history is tamper-evident by append. Secrets are scrubbed via the chunk-1
|
|
8
|
+
// logger's redact() before the line is written.
|
|
9
|
+
//
|
|
10
|
+
// The base dir is injectable (same convention as ConfigStore/StateStore): explicit
|
|
11
|
+
// ctor arg > env DAB_HOME > ~/.discord-agent-bridge/, so tests never touch the real
|
|
12
|
+
// home. The clock is injectable so the `when` timestamp is deterministic in tests.
|
|
13
|
+
//
|
|
14
|
+
// Optional Discord channel sink: an injectable callback the later Discord chunk
|
|
15
|
+
// wires to a channel emitter (§7.5 "optionally mirrored"). discord.js is NOT
|
|
16
|
+
// imported here — core stays mode/transport-agnostic.
|
|
17
|
+
const DEFAULT_DIR_NAME = '.discord-agent-bridge';
|
|
18
|
+
const AUDIT_FILE_NAME = 'audit.jsonl';
|
|
19
|
+
function defaultBaseDir() {
|
|
20
|
+
const fromEnv = process.env.DAB_HOME;
|
|
21
|
+
if (fromEnv && fromEnv.length > 0)
|
|
22
|
+
return fromEnv;
|
|
23
|
+
return path.join(os.homedir(), DEFAULT_DIR_NAME);
|
|
24
|
+
}
|
|
25
|
+
export class AuditLog {
|
|
26
|
+
baseDir;
|
|
27
|
+
now;
|
|
28
|
+
sink;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.baseDir = options.baseDir ?? defaultBaseDir();
|
|
31
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
32
|
+
this.sink = options.sink;
|
|
33
|
+
}
|
|
34
|
+
get dir() {
|
|
35
|
+
return path.join(this.baseDir, 'audit');
|
|
36
|
+
}
|
|
37
|
+
get filePath() {
|
|
38
|
+
return path.join(this.dir, AUDIT_FILE_NAME);
|
|
39
|
+
}
|
|
40
|
+
// Stamp the timestamp, redact secrets, append one JSON line. The dir is created
|
|
41
|
+
// if missing. Best-effort: a transient fs failure (mkdir/append) is logged
|
|
42
|
+
// loudly but NEVER thrown to the caller — audit is on the fire-and-forget turn
|
|
43
|
+
// pipeline (sessionOrchestrator drain), so a write failure must not stall a
|
|
44
|
+
// channel's turn queue. The optional sink still runs and the record is still
|
|
45
|
+
// returned even when the durable append failed.
|
|
46
|
+
record(entry) {
|
|
47
|
+
const record = { ...entry, timestamp: this.now() };
|
|
48
|
+
const scrubbed = redact(record);
|
|
49
|
+
try {
|
|
50
|
+
this.ensureDir();
|
|
51
|
+
fs.appendFileSync(this.filePath, JSON.stringify(scrubbed) + '\n', { encoding: 'utf-8' });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.error(`[audit] failed to write ${this.filePath}: ${String(err)}`);
|
|
55
|
+
}
|
|
56
|
+
this.sink?.(scrubbed);
|
|
57
|
+
return scrubbed;
|
|
58
|
+
}
|
|
59
|
+
ensureDir() {
|
|
60
|
+
if (!fs.existsSync(this.dir)) {
|
|
61
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=auditLog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auditLog.js","sourceRoot":"","sources":["../../src/core/auditLog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9B,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,gDAAgD;AAChD,EAAE;AACF,mFAAmF;AACnF,oFAAoF;AACpF,mFAAmF;AACnF,EAAE;AACF,gFAAgF;AAChF,6EAA6E;AAC7E,sDAAsD;AACtD,MAAM,gBAAgB,GAAG,uBAAuB,CAAC;AACjD,MAAM,eAAe,GAAG,aAAa,CAAC;AAEtC,SAAS,cAAc;IACrB,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;IACrC,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,OAAO,CAAC;IAClD,OAAO,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,gBAAgB,CAAC,CAAC;AACnD,CAAC;AAcD,MAAM,OAAO,QAAQ;IACF,OAAO,CAAS;IAChB,GAAG,CAAe;IAClB,IAAI,CAAa;IAElC,YAAY,UAA2B,EAAE;QACvC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,cAAc,EAAE,CAAC;QACnD,IAAI,CAAC,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;QAC3D,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3B,CAAC;IAED,IAAI,GAAG;QACL,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC1C,CAAC;IAED,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;IAC9C,CAAC;IAED,gFAAgF;IAChF,2EAA2E;IAC3E,+EAA+E;IAC/E,4EAA4E;IAC5E,6EAA6E;IAC7E,gDAAgD;IAChD,MAAM,CAAC,KAAiB;QACtB,MAAM,MAAM,GAAgB,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;QAChE,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAgB,CAAC;QAC/C,IAAI,CAAC;YACH,IAAI,CAAC,SAAS,EAAE,CAAC;YACjB,EAAE,CAAC,cAAc,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,GAAG,IAAI,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC;QAC3F,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,2BAA2B,IAAI,CAAC,QAAQ,KAAK,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC5E,CAAC;QACD,IAAI,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,CAAC;QACtB,OAAO,QAAQ,CAAC;IAClB,CAAC;IAEO,SAAS;QACf,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9C,CAAC;IACH,CAAC;CACF"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { AuditLog } from './auditLog.js';
|
|
6
|
+
const FIXED_NOW = '2026-01-01T00:00:00.000Z';
|
|
7
|
+
function entry(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
actorId: 'u1',
|
|
10
|
+
roleTier: 'execute',
|
|
11
|
+
guildId: 'g1',
|
|
12
|
+
channelId: 'c1',
|
|
13
|
+
action: 'turn',
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('AuditLog', () => {
|
|
18
|
+
let dir;
|
|
19
|
+
function build(sink) {
|
|
20
|
+
return new AuditLog({ baseDir: dir, now: () => FIXED_NOW, sink });
|
|
21
|
+
}
|
|
22
|
+
function readLines(log) {
|
|
23
|
+
return fs
|
|
24
|
+
.readFileSync(log.filePath, 'utf-8')
|
|
25
|
+
.split('\n')
|
|
26
|
+
.filter((l) => l.length > 0);
|
|
27
|
+
}
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-audit-'));
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
it('creates the audit dir and appends one JSON line', () => {
|
|
35
|
+
const log = build();
|
|
36
|
+
expect(fs.existsSync(log.dir)).toBe(false); // dir not created until first record
|
|
37
|
+
log.record(entry({ action: 'command', command: 'ls -la' }));
|
|
38
|
+
expect(fs.existsSync(log.dir)).toBe(true);
|
|
39
|
+
const lines = readLines(log);
|
|
40
|
+
expect(lines).toHaveLength(1);
|
|
41
|
+
const parsed = JSON.parse(lines[0]);
|
|
42
|
+
expect(parsed.action).toBe('command');
|
|
43
|
+
expect(parsed.command).toBe('ls -la');
|
|
44
|
+
});
|
|
45
|
+
it('stamps the injected clock as the deterministic timestamp', () => {
|
|
46
|
+
const log = build();
|
|
47
|
+
const rec = log.record(entry());
|
|
48
|
+
expect(rec.timestamp).toBe(FIXED_NOW);
|
|
49
|
+
const parsed = JSON.parse(readLines(log)[0]);
|
|
50
|
+
expect(parsed.timestamp).toBe(FIXED_NOW);
|
|
51
|
+
});
|
|
52
|
+
it('appends (does not overwrite) across multiple records', () => {
|
|
53
|
+
const log = build();
|
|
54
|
+
log.record(entry({ action: 'turn' }));
|
|
55
|
+
log.record(entry({ action: 'command', command: 'git status' }));
|
|
56
|
+
log.record(entry({ action: 'tool', tool: 'Read' }));
|
|
57
|
+
const lines = readLines(log);
|
|
58
|
+
expect(lines).toHaveLength(3);
|
|
59
|
+
expect(JSON.parse(lines[0]).action).toBe('turn');
|
|
60
|
+
expect(JSON.parse(lines[1]).command).toBe('git status');
|
|
61
|
+
expect(JSON.parse(lines[2]).tool).toBe('Read');
|
|
62
|
+
});
|
|
63
|
+
it('redacts secrets in the written line', () => {
|
|
64
|
+
const log = build();
|
|
65
|
+
// Synthetic Discord-token-SHAPE value (23-28 . 6-7 . 27-40 chars), assembled
|
|
66
|
+
// at runtime so no realistic token literal lives in source.
|
|
67
|
+
const fakeToken = ['X'.repeat(24), 'Y'.repeat(6), 'Z'.repeat(28)].join('.');
|
|
68
|
+
log.record(entry({ action: 'command', command: `login ${fakeToken}` }));
|
|
69
|
+
const raw = fs.readFileSync(log.filePath, 'utf-8');
|
|
70
|
+
expect(raw).not.toContain(fakeToken);
|
|
71
|
+
expect(raw).toContain('[REDACTED]');
|
|
72
|
+
// Non-secret fields survive redaction.
|
|
73
|
+
expect(JSON.parse(readLines(log)[0]).actorId).toBe('u1');
|
|
74
|
+
});
|
|
75
|
+
it('is best-effort: a forced write failure does not throw out of record()', () => {
|
|
76
|
+
// Plant a regular FILE where the audit dir would go, so mkdir is skipped
|
|
77
|
+
// (existsSync true) and the append under it fails with ENOTDIR. record()
|
|
78
|
+
// must swallow this — a transient audit failure cannot stall the turn queue.
|
|
79
|
+
const log = build();
|
|
80
|
+
fs.writeFileSync(log.dir, 'not a directory');
|
|
81
|
+
const errors = [];
|
|
82
|
+
const orig = console.error;
|
|
83
|
+
console.error = (...a) => errors.push(a);
|
|
84
|
+
try {
|
|
85
|
+
const rec = log.record(entry({ action: 'turn' }));
|
|
86
|
+
// No throw; the record is still returned and the sink still runs.
|
|
87
|
+
expect(rec.action).toBe('turn');
|
|
88
|
+
expect(rec.timestamp).toBe(FIXED_NOW);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
console.error = orig;
|
|
92
|
+
}
|
|
93
|
+
// The failure was logged loudly.
|
|
94
|
+
expect(errors.length).toBe(1);
|
|
95
|
+
// Nothing was durably written (the append failed).
|
|
96
|
+
expect(fs.existsSync(log.filePath)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('still runs the sink even when the durable append fails', () => {
|
|
99
|
+
const seen = [];
|
|
100
|
+
const log = build((r) => seen.push(r));
|
|
101
|
+
fs.writeFileSync(log.dir, 'not a directory');
|
|
102
|
+
const orig = console.error;
|
|
103
|
+
console.error = () => { };
|
|
104
|
+
try {
|
|
105
|
+
log.record(entry({ action: 'turn' }));
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
console.error = orig;
|
|
109
|
+
}
|
|
110
|
+
expect(seen).toHaveLength(1);
|
|
111
|
+
expect(seen[0].timestamp).toBe(FIXED_NOW);
|
|
112
|
+
});
|
|
113
|
+
it('invokes the optional sink with the redacted record', () => {
|
|
114
|
+
const seen = [];
|
|
115
|
+
const log = build((r) => seen.push(r));
|
|
116
|
+
const fakeToken = ['A'.repeat(24), 'B'.repeat(6), 'C'.repeat(28)].join('.');
|
|
117
|
+
log.record(entry({ action: 'command', command: `run ${fakeToken}` }));
|
|
118
|
+
expect(seen).toHaveLength(1);
|
|
119
|
+
expect(seen[0].timestamp).toBe(FIXED_NOW);
|
|
120
|
+
expect(JSON.stringify(seen[0])).not.toContain(fakeToken);
|
|
121
|
+
expect(JSON.stringify(seen[0])).toContain('[REDACTED]');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
//# sourceMappingURL=auditLog.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auditLog.test.js","sourceRoot":"","sources":["../../src/core/auditLog.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Tier ranking for the ⊇ relation (higher grants everything a lower one does).
|
|
2
|
+
const TIER_RANK = { 'read-only': 1, execute: 2, admin: 3 };
|
|
3
|
+
// Minimum tier each action requires. 'drive' (start session / run turn) and
|
|
4
|
+
// 'run-command' (!cmd) are the execute-tier "driver" actions (§7.1); 'read'
|
|
5
|
+
// (status/transcript/usage) needs only read-only; 'admin' needs admin.
|
|
6
|
+
const ACTION_MIN_TIER = {
|
|
7
|
+
admin: 'admin',
|
|
8
|
+
drive: 'execute',
|
|
9
|
+
'run-command': 'execute',
|
|
10
|
+
read: 'read-only',
|
|
11
|
+
};
|
|
12
|
+
export class Authorizer {
|
|
13
|
+
configStore;
|
|
14
|
+
channelRegistry;
|
|
15
|
+
constructor(configStore, channelRegistry) {
|
|
16
|
+
this.configStore = configStore;
|
|
17
|
+
this.channelRegistry = channelRegistry;
|
|
18
|
+
}
|
|
19
|
+
authorize(input) {
|
|
20
|
+
const { guildId, channelId } = input.context;
|
|
21
|
+
// No guild → DM. Deny unless dmPolicy explicitly allows (deny-by-default, §7.1).
|
|
22
|
+
if (guildId === undefined) {
|
|
23
|
+
const global = this.configStore.load();
|
|
24
|
+
if (global.auth.dmPolicy !== 'allow') {
|
|
25
|
+
return { allowed: false, reason: 'DMs are not permitted (dmPolicy=deny).' };
|
|
26
|
+
}
|
|
27
|
+
// DM allowed: fall through with global-only allowlists (no server layer for DMs).
|
|
28
|
+
return this.decide(this.effectiveAuth(global, null), input, undefined);
|
|
29
|
+
}
|
|
30
|
+
const global = this.configStore.load();
|
|
31
|
+
const server = this.configStore.loadServerConfig(guildId);
|
|
32
|
+
const effective = this.effectiveAuth(global, server);
|
|
33
|
+
const binding = channelId !== undefined ? this.channelRegistry.get(guildId, channelId) : undefined;
|
|
34
|
+
return this.decide(effective, input, binding?.projectAuth);
|
|
35
|
+
}
|
|
36
|
+
// Layer server auth over global. A server allowlist, when present, replaces that
|
|
37
|
+
// tier's global list (server-scoped override, §7.1/§8.1); an absent server field
|
|
38
|
+
// falls through to global.
|
|
39
|
+
//
|
|
40
|
+
// AUTH MODEL (decided 2026-07-01): the server layer OVERRIDES per tier — a
|
|
41
|
+
// present server list REPLACES the global list for that tier and MAY WIDEN or
|
|
42
|
+
// NARROW it (it is not constrained to an intersection). The project-level ACL
|
|
43
|
+
// (projectAuth on the channel binding) then INTERSECTS on top (narrows only).
|
|
44
|
+
// So widening happens only at the server layer; the project layer can only
|
|
45
|
+
// further restrict. See auth.test.ts (server override widens) + §7.1/§8.1.
|
|
46
|
+
effectiveAuth(global, server) {
|
|
47
|
+
const g = global.auth;
|
|
48
|
+
const s = server?.auth;
|
|
49
|
+
return {
|
|
50
|
+
adminRoleIds: s?.adminRoleIds ?? g.adminRoleIds,
|
|
51
|
+
executeRoleIds: s?.executeRoleIds ?? g.executeRoleIds,
|
|
52
|
+
readOnlyRoleIds: s?.readOnlyRoleIds ?? g.readOnlyRoleIds,
|
|
53
|
+
dmPolicy: g.dmPolicy,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Resolve the actor's highest tier from the allowlists, then check it clears the
|
|
57
|
+
// action's minimum tier and (if present) the per-project ACL.
|
|
58
|
+
decide(auth, input, projectAuth) {
|
|
59
|
+
// A Discord Administrator is ALWAYS the admin tier and bypasses both the role
|
|
60
|
+
// allowlists and the per-project ACL — the operator who set the bot up can never
|
|
61
|
+
// lock themselves out (e.g. by pointing the admin tier at the wrong role). This is
|
|
62
|
+
// the only widening path; every other actor stays deny-by-default below.
|
|
63
|
+
if (input.isAdministrator === true) {
|
|
64
|
+
return { allowed: true, tier: 'admin' };
|
|
65
|
+
}
|
|
66
|
+
const tier = this.resolveTier(auth, input.roleIds);
|
|
67
|
+
if (tier === null) {
|
|
68
|
+
return { allowed: false, reason: 'No authorized role for this actor (fail-secure).' };
|
|
69
|
+
}
|
|
70
|
+
const required = ACTION_MIN_TIER[input.action];
|
|
71
|
+
if (TIER_RANK[tier] < TIER_RANK[required]) {
|
|
72
|
+
return {
|
|
73
|
+
allowed: false,
|
|
74
|
+
reason: `Action '${input.action}' requires '${required}'; actor tier is '${tier}'.`,
|
|
75
|
+
tier,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Per-project ACL narrows access: when a binding lists allowed roles/users, the
|
|
79
|
+
// actor must match one of them IN ADDITION to clearing the tier check (§7.1).
|
|
80
|
+
if (projectAuth) {
|
|
81
|
+
const allowedByUser = projectAuth.allowedUserIds.includes(input.userId);
|
|
82
|
+
const allowedByRole = input.roleIds.some((r) => projectAuth.allowedRoleIds.includes(r));
|
|
83
|
+
if (!allowedByUser && !allowedByRole) {
|
|
84
|
+
return {
|
|
85
|
+
allowed: false,
|
|
86
|
+
reason: 'Actor is not in this project’s access list (projectAuth).',
|
|
87
|
+
tier,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { allowed: true, tier };
|
|
92
|
+
}
|
|
93
|
+
// Highest tier the actor's roles grant, or null if none match (deny-by-default).
|
|
94
|
+
resolveTier(auth, roleIds) {
|
|
95
|
+
const roles = new Set(roleIds);
|
|
96
|
+
const has = (allow) => allow.some((id) => roles.has(id));
|
|
97
|
+
if (has(auth.adminRoleIds))
|
|
98
|
+
return 'admin';
|
|
99
|
+
if (has(auth.executeRoleIds))
|
|
100
|
+
return 'execute';
|
|
101
|
+
if (has(auth.readOnlyRoleIds))
|
|
102
|
+
return 'read-only';
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/core/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { ConfigStore } from './config.js';
|
|
6
|
+
import { CONFIG_VERSION } from './configSchema.js';
|
|
7
|
+
import { StateStore } from './state/store.js';
|
|
8
|
+
import { ChannelRegistry } from './channelRegistry.js';
|
|
9
|
+
import { Authorizer } from './auth.js';
|
|
10
|
+
// Zero-entropy fixture ids: no realistic secret/token shapes.
|
|
11
|
+
const ADMIN_ROLE = 'role-admin';
|
|
12
|
+
const EXEC_ROLE = 'role-exec';
|
|
13
|
+
const READ_ROLE = 'role-read';
|
|
14
|
+
function makeConfig(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
version: CONFIG_VERSION,
|
|
17
|
+
discord: { token: 'bot-token-abc', clientId: '123456789' },
|
|
18
|
+
auth: { adminRoleIds: [], executeRoleIds: [], readOnlyRoleIds: [], dmPolicy: 'deny' },
|
|
19
|
+
defaults: {
|
|
20
|
+
mode: 'claude',
|
|
21
|
+
claudeModel: 'opus',
|
|
22
|
+
codexModel: '',
|
|
23
|
+
permissionMode: 'default',
|
|
24
|
+
permissionProfile: null,
|
|
25
|
+
codexHome: '~/.codex',
|
|
26
|
+
codexCliCommand: 'codex',
|
|
27
|
+
codexCliVersion: null,
|
|
28
|
+
},
|
|
29
|
+
limits: { maxSessionsPerUser: 0, permissionTimeoutSec: 60, codexTimeoutMs: 1_800_000 },
|
|
30
|
+
policy: { unknownCommand: 'confirm', allowExtraCommands: [] },
|
|
31
|
+
autoAllowClaudeTools: ['Read', 'Glob', 'Grep'],
|
|
32
|
+
profiles: {},
|
|
33
|
+
usage: { userAgent: 'claude-code', cacheSec: 180 },
|
|
34
|
+
audit: { channelId: null },
|
|
35
|
+
locale: 'ko',
|
|
36
|
+
logLevel: 'info',
|
|
37
|
+
favorites: [],
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function binding(overrides = {}) {
|
|
42
|
+
return {
|
|
43
|
+
guildId: 'g1',
|
|
44
|
+
channelId: 'c1',
|
|
45
|
+
mode: 'claude',
|
|
46
|
+
sessionId: null,
|
|
47
|
+
cwd: '/abs/workspace',
|
|
48
|
+
ownerId: 'u1',
|
|
49
|
+
permMode: 'default',
|
|
50
|
+
profile: null,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
describe('Authorizer', () => {
|
|
55
|
+
let dir;
|
|
56
|
+
let store;
|
|
57
|
+
function build() {
|
|
58
|
+
const registry = new ChannelRegistry(new StateStore(dir), () => '2026-01-01T00:00:00.000Z');
|
|
59
|
+
const authz = new Authorizer(store, registry);
|
|
60
|
+
return { authz, registry };
|
|
61
|
+
}
|
|
62
|
+
function input(overrides = {}) {
|
|
63
|
+
return {
|
|
64
|
+
userId: 'u1',
|
|
65
|
+
roleIds: [],
|
|
66
|
+
action: 'read',
|
|
67
|
+
context: { guildId: 'g1', channelId: 'c1' },
|
|
68
|
+
...overrides,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dab-auth-'));
|
|
73
|
+
store = new ConfigStore(dir);
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
it('fail-secure: empty allowlists deny everyone, even for read', () => {
|
|
79
|
+
store.save(makeConfig());
|
|
80
|
+
const { authz } = build();
|
|
81
|
+
const r = authz.authorize(input({ roleIds: [ADMIN_ROLE], action: 'read' }));
|
|
82
|
+
expect(r.allowed).toBe(false);
|
|
83
|
+
expect(r.reason).toMatch(/fail-secure/i);
|
|
84
|
+
});
|
|
85
|
+
it('admin tier is allowed every action', () => {
|
|
86
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, adminRoleIds: [ADMIN_ROLE] } }));
|
|
87
|
+
const { authz } = build();
|
|
88
|
+
for (const action of ['admin', 'drive', 'run-command', 'read']) {
|
|
89
|
+
const r = authz.authorize(input({ roleIds: [ADMIN_ROLE], action }));
|
|
90
|
+
expect(r.allowed).toBe(true);
|
|
91
|
+
expect(r.tier).toBe('admin');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it('execute tier may drive and run commands but not admin', () => {
|
|
95
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, executeRoleIds: [EXEC_ROLE] } }));
|
|
96
|
+
const { authz } = build();
|
|
97
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive' })).allowed).toBe(true);
|
|
98
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'run-command' })).allowed).toBe(true);
|
|
99
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'read' })).allowed).toBe(true);
|
|
100
|
+
const denied = authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'admin' }));
|
|
101
|
+
expect(denied.allowed).toBe(false);
|
|
102
|
+
expect(denied.tier).toBe('execute');
|
|
103
|
+
});
|
|
104
|
+
it('read-only tier may read but not drive/run-command/admin', () => {
|
|
105
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, readOnlyRoleIds: [READ_ROLE] } }));
|
|
106
|
+
const { authz } = build();
|
|
107
|
+
expect(authz.authorize(input({ roleIds: [READ_ROLE], action: 'read' })).allowed).toBe(true);
|
|
108
|
+
expect(authz.authorize(input({ roleIds: [READ_ROLE], action: 'drive' })).allowed).toBe(false);
|
|
109
|
+
expect(authz.authorize(input({ roleIds: [READ_ROLE], action: 'run-command' })).allowed).toBe(false);
|
|
110
|
+
expect(authz.authorize(input({ roleIds: [READ_ROLE], action: 'admin' })).allowed).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
it('unknown/no role → denied (fail-secure)', () => {
|
|
113
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, executeRoleIds: [EXEC_ROLE] } }));
|
|
114
|
+
const { authz } = build();
|
|
115
|
+
expect(authz.authorize(input({ roleIds: [], action: 'read' })).allowed).toBe(false);
|
|
116
|
+
expect(authz.authorize(input({ roleIds: ['role-stranger'], action: 'read' })).allowed).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
it('server-level roles override/extend the global allowlist for that guild only', () => {
|
|
119
|
+
// Global grants nobody execute; the server grants EXEC_ROLE.
|
|
120
|
+
store.save(makeConfig());
|
|
121
|
+
const server = {
|
|
122
|
+
version: 1,
|
|
123
|
+
guildId: 'g1',
|
|
124
|
+
auth: { executeRoleIds: [EXEC_ROLE] },
|
|
125
|
+
};
|
|
126
|
+
store.saveServerConfig(server);
|
|
127
|
+
const { authz } = build();
|
|
128
|
+
// In g1, the server grant applies.
|
|
129
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive', context: { guildId: 'g1', channelId: 'c1' } })).allowed).toBe(true);
|
|
130
|
+
// In g2 (no server file) the global (empty) applies → denied.
|
|
131
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive', context: { guildId: 'g2', channelId: 'c1' } })).allowed).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
it('server auth for one tier does not clear the global list of another tier', () => {
|
|
134
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, adminRoleIds: [ADMIN_ROLE] } }));
|
|
135
|
+
store.saveServerConfig({ version: 1, guildId: 'g1', auth: { executeRoleIds: [EXEC_ROLE] } });
|
|
136
|
+
const { authz } = build();
|
|
137
|
+
// adminRoleIds falls through from global since the server only set executeRoleIds.
|
|
138
|
+
expect(authz.authorize(input({ roleIds: [ADMIN_ROLE], action: 'admin' })).allowed).toBe(true);
|
|
139
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive' })).allowed).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it('per-project ACL narrows: a tier-cleared actor is still denied when not on the project list', () => {
|
|
142
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, executeRoleIds: [EXEC_ROLE] } }));
|
|
143
|
+
const { authz, registry } = build();
|
|
144
|
+
registry.set(binding({ projectAuth: { allowedRoleIds: ['role-project'], allowedUserIds: [] } }));
|
|
145
|
+
const denied = authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive' }));
|
|
146
|
+
expect(denied.allowed).toBe(false);
|
|
147
|
+
expect(denied.reason).toMatch(/projectAuth/);
|
|
148
|
+
expect(denied.tier).toBe('execute');
|
|
149
|
+
});
|
|
150
|
+
it('per-project ACL admits a matching role or a matching user', () => {
|
|
151
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, executeRoleIds: [EXEC_ROLE] } }));
|
|
152
|
+
const { authz, registry } = build();
|
|
153
|
+
registry.set(binding({ projectAuth: { allowedRoleIds: [EXEC_ROLE], allowedUserIds: [] } }));
|
|
154
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive' })).allowed).toBe(true);
|
|
155
|
+
registry.set(binding({ projectAuth: { allowedRoleIds: ['role-other'], allowedUserIds: ['u1'] } }));
|
|
156
|
+
expect(authz.authorize(input({ userId: 'u1', roleIds: [EXEC_ROLE], action: 'drive' })).allowed).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
it('DM (no guild) is denied by default', () => {
|
|
159
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, adminRoleIds: [ADMIN_ROLE] } }));
|
|
160
|
+
const { authz } = build();
|
|
161
|
+
const r = authz.authorize(input({ roleIds: [ADMIN_ROLE], action: 'read', context: {} }));
|
|
162
|
+
expect(r.allowed).toBe(false);
|
|
163
|
+
expect(r.reason).toMatch(/dmPolicy=deny/);
|
|
164
|
+
});
|
|
165
|
+
it('DM is allowed when dmPolicy=allow and the actor clears the tier', () => {
|
|
166
|
+
store.save(makeConfig({ auth: { adminRoleIds: [ADMIN_ROLE], executeRoleIds: [], readOnlyRoleIds: [], dmPolicy: 'allow' } }));
|
|
167
|
+
const { authz } = build();
|
|
168
|
+
expect(authz.authorize(input({ roleIds: [ADMIN_ROLE], action: 'admin', context: {} })).allowed).toBe(true);
|
|
169
|
+
// Still tier-gated in a DM: a stranger is denied.
|
|
170
|
+
expect(authz.authorize(input({ roleIds: [], action: 'read', context: {} })).allowed).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
// A Discord Administrator is granted the admin tier UNCONDITIONALLY (never locked
|
|
173
|
+
// out), even with a completely empty role allowlist — the fix for the /agent start
|
|
174
|
+
// lockout footgun. Role tiers remain additive for non-admins.
|
|
175
|
+
it('a Discord Administrator with NO configured role is authorized as admin', () => {
|
|
176
|
+
store.save(makeConfig()); // empty allowlists
|
|
177
|
+
const { authz } = build();
|
|
178
|
+
for (const action of ['admin', 'drive', 'run-command', 'read']) {
|
|
179
|
+
const r = authz.authorize(input({ roleIds: [], action, isAdministrator: true }));
|
|
180
|
+
expect(r.allowed).toBe(true);
|
|
181
|
+
expect(r.tier).toBe('admin');
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
it('a non-admin with no configured role is denied (deny-by-default preserved)', () => {
|
|
185
|
+
store.save(makeConfig()); // empty allowlists
|
|
186
|
+
const { authz } = build();
|
|
187
|
+
const r = authz.authorize(input({ roleIds: [], action: 'read', isAdministrator: false }));
|
|
188
|
+
expect(r.allowed).toBe(false);
|
|
189
|
+
expect(r.reason).toMatch(/fail-secure/i);
|
|
190
|
+
});
|
|
191
|
+
it('configured role tiers still work for a non-Administrator (additive, unchanged)', () => {
|
|
192
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, executeRoleIds: [EXEC_ROLE] } }));
|
|
193
|
+
const { authz } = build();
|
|
194
|
+
// The configured execute role drives without any Administrator flag.
|
|
195
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'drive' })).allowed).toBe(true);
|
|
196
|
+
// ...but that same execute role is NOT admin.
|
|
197
|
+
expect(authz.authorize(input({ roleIds: [EXEC_ROLE], action: 'admin' })).allowed).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
it('a Discord Administrator bypasses a narrowing per-project ACL (never locked out)', () => {
|
|
200
|
+
store.save(makeConfig({ auth: { ...makeConfig().auth, executeRoleIds: [EXEC_ROLE] } }));
|
|
201
|
+
const { authz, registry } = build();
|
|
202
|
+
// A project ACL that lists neither the admin's role nor user would normally deny.
|
|
203
|
+
registry.set(binding({ projectAuth: { allowedRoleIds: ['role-project'], allowedUserIds: [] } }));
|
|
204
|
+
const r = authz.authorize(input({ roleIds: [], action: 'drive', isAdministrator: true }));
|
|
205
|
+
expect(r.allowed).toBe(true);
|
|
206
|
+
expect(r.tier).toBe('admin');
|
|
207
|
+
});
|
|
208
|
+
it('a denied DM is NOT rescued by the Administrator flag (dmPolicy stays authoritative)', () => {
|
|
209
|
+
store.save(makeConfig()); // dmPolicy defaults to 'deny'
|
|
210
|
+
const { authz } = build();
|
|
211
|
+
const r = authz.authorize(input({ roleIds: [], action: 'read', context: {}, isAdministrator: true }));
|
|
212
|
+
expect(r.allowed).toBe(false);
|
|
213
|
+
expect(r.reason).toMatch(/dmPolicy=deny/);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
//# sourceMappingURL=auth.test.js.map
|