Skip to content

Commit d85e7e9

Browse files
committed
feat(opencode-plugin): permissions system
1 parent 7820b3d commit d85e7e9

2 files changed

Lines changed: 67 additions & 34 deletions

File tree

packages/opencode-plugin/src/plugin.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin
641641
* an error if the agent is not allowed to use workflows.
642642
*/
643643
tool: await (async (): Promise<{ [key: string]: ToolDefinition }> => {
644-
const wrap = (def: ToolDefinition): ToolDefinition => ({
644+
const wrap = (toolName: string, def: ToolDefinition): ToolDefinition => ({
645645
...def,
646646
execute: async (args, ctx) => {
647647
const agent = ctx.agent;
@@ -652,31 +652,45 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin
652652
);
653653
}
654654

655+
await ctx.ask({
656+
permission: toolName,
657+
patterns: ['*'],
658+
always: ['*'],
659+
metadata: {},
660+
});
661+
655662
return def.execute(args, ctx);
656663
},
657664
});
658665

659666
return {
660667
start_development: wrap(
668+
'start_development',
661669
createStartDevelopmentTool(
662670
input.directory,
663671
getServerContext,
664672
setBufferedInstructions
665673
)
666674
),
667675
proceed_to_phase: wrap(
676+
'proceed_to_phase',
668677
createProceedToPhaseTool(
669678
getServerContext,
670679
setBufferedInstructions,
671680
input.client as OpenCodeClient,
672681
() => lastKnownModel
673682
)
674683
),
675-
conduct_review: wrap(createConductReviewTool(getServerContext)),
684+
conduct_review: wrap(
685+
'conduct_review',
686+
createConductReviewTool(getServerContext)
687+
),
676688
reset_development: wrap(
689+
'reset_development',
677690
createResetDevelopmentTool(input.directory, getServerContext)
678691
),
679692
setup_project_docs: wrap(
693+
'setup_project_docs',
680694
await createSetupProjectDocsTool(input.directory, getServerContext)
681695
),
682696
};

packages/opencode-plugin/test/e2e/plugin.test.ts

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ function cleanupDir(dir: string): void {
5050
}
5151
}
5252

53+
/**
54+
* Create a mock ToolContext for testing.
55+
* Includes a no-op `ask` spy so permission checks in the plugin's `wrap()` work.
56+
*/
57+
function createMockToolContext(overrides: Record<string, unknown> = {}) {
58+
return {
59+
sessionID: 'test-session',
60+
messageID: 'test-message',
61+
agent: 'workflow',
62+
directory: '',
63+
worktree: '',
64+
abort: new AbortController().signal,
65+
metadata: vi.fn(),
66+
ask: vi.fn().mockResolvedValue(undefined),
67+
...overrides,
68+
};
69+
}
70+
5371
/**
5472
* Create a mock PluginInput for testing
5573
*/
@@ -571,7 +589,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
571589

572590
const result = await hooks.tool!.start_development.execute(
573591
{ workflow: 'epcc' },
574-
{} as never
592+
createMockToolContext()
575593
);
576594

577595
// start_development returns instructions from handler
@@ -592,7 +610,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
592610

593611
const result = await hooks.tool!.start_development.execute(
594612
{ workflow: 'waterfall' },
595-
{} as never
613+
createMockToolContext()
596614
);
597615

598616
// When trying to start a different workflow, we get an error about existing workflow
@@ -615,7 +633,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
615633

616634
const result = await hooks.tool!.proceed_to_phase.execute(
617635
{ target_phase: 'plan', reason: 'exploration complete' },
618-
{} as never
636+
createMockToolContext()
619637
);
620638

621639
// Transition output now shows the new phase clearly
@@ -645,7 +663,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
645663
const sessionID = 'test-session-123';
646664
await hooks.tool!.proceed_to_phase.execute(
647665
{ target_phase: 'plan', reason: 'exploration complete' },
648-
{ sessionID } as never
666+
createMockToolContext({ sessionID })
649667
);
650668

651669
// session.summarize should have been called with the session ID and model
@@ -664,9 +682,10 @@ describe('OpenCode Workflows Plugin E2E', () => {
664682
hooks = await WorkflowsPlugin(mockInput);
665683

666684
const sessionID = 'test-session-456';
667-
await hooks.tool!.proceed_to_phase.execute({ target_phase: 'plan' }, {
668-
sessionID,
669-
} as never);
685+
await hooks.tool!.proceed_to_phase.execute(
686+
{ target_phase: 'plan' },
687+
createMockToolContext({ sessionID })
688+
);
670689

671690
// session.summarize should NOT have been called — transition failed
672691
const summarizeMock = (
@@ -708,7 +727,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
708727
const sessionID = 'test-session-789';
709728
const proceedResult = await hooks.tool!.proceed_to_phase.execute(
710729
{ target_phase: 'plan', reason: 'exploration complete' },
711-
{ sessionID } as never
730+
createMockToolContext({ sessionID })
712731
);
713732

714733
// Verify the transition itself succeeded before checking compaction was skipped
@@ -728,7 +747,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
728747

729748
const result = await hooks.tool!.proceed_to_phase.execute(
730749
{ target_phase: 'plan' },
731-
{} as never
750+
createMockToolContext()
732751
);
733752

734753
// Error message from handler mentions "No development conversation" or similar
@@ -750,7 +769,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
750769

751770
const result = await hooks.tool!.reset_development.execute(
752771
{ confirm: false },
753-
{} as never
772+
createMockToolContext()
754773
);
755774

756775
expect(result).toContain('confirm');
@@ -766,7 +785,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
766785

767786
const result = await hooks.tool!.reset_development.execute(
768787
{ confirm: true, reason: 'testing reset' },
769-
{} as never
788+
createMockToolContext()
770789
);
771790

772791
// Reset message confirms deletion (may not include workflow name)
@@ -778,7 +797,7 @@ describe('OpenCode Workflows Plugin E2E', () => {
778797

779798
const result = await hooks.tool!.reset_development.execute(
780799
{ confirm: true },
781-
{} as never
800+
createMockToolContext()
782801
);
783802

784803
expect(result).toContain('No active workflow');
@@ -921,10 +940,10 @@ describe('WORKFLOW_AGENTS environment variable', () => {
921940

922941
// Tool should throw when agent is not in filter
923942
await expect(
924-
hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
925-
sessionID: 'test-session',
926-
agent: 'explore', // Not in filter
927-
} as unknown)
943+
hooks.tool!['start_development'].execute(
944+
{ workflow: 'minor' },
945+
createMockToolContext({ agent: 'explore' }) // Not in filter
946+
)
928947
).rejects.toThrow(/not enabled for this agent/i);
929948
} finally {
930949
if (originalEnv === undefined) {
@@ -948,10 +967,10 @@ describe('WORKFLOW_AGENTS environment variable', () => {
948967
// (it may fail for other reasons like no plan file, but not the filter guard)
949968
let thrownMessage: string | undefined;
950969
try {
951-
await hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
952-
sessionID: 'test-session',
953-
agent: 'general', // In filter
954-
} as unknown);
970+
await hooks.tool!['start_development'].execute(
971+
{ workflow: 'minor' },
972+
createMockToolContext({ agent: 'general' }) // In filter
973+
);
955974
} catch (err) {
956975
thrownMessage = (err as Error).message;
957976
}
@@ -989,10 +1008,10 @@ describe('WORKFLOW_AGENTS environment variable', () => {
9891008
// Tool should not throw agent filter error for any agent
9901009
let thrownMessage: string | undefined;
9911010
try {
992-
await hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
993-
sessionID: 'test-session',
994-
agent: 'any-agent', // Should work when no filter is set
995-
} as unknown);
1011+
await hooks.tool!['start_development'].execute(
1012+
{ workflow: 'minor' },
1013+
createMockToolContext({ agent: 'any-agent' }) // Should work when no filter is set
1014+
);
9961015
} catch (err) {
9971016
thrownMessage = (err as Error).message;
9981017
}
@@ -1020,10 +1039,10 @@ describe('WORKFLOW_AGENTS environment variable', () => {
10201039
// Should parse correctly and allow the whitelisted agents
10211040
let thrownMessage: string | undefined;
10221041
try {
1023-
await hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
1024-
sessionID: 'test-session',
1025-
agent: 'architect', // Should work after trimming
1026-
} as unknown);
1042+
await hooks.tool!['start_development'].execute(
1043+
{ workflow: 'minor' },
1044+
createMockToolContext({ agent: 'architect' }) // Should work after trimming
1045+
);
10271046
} catch (err) {
10281047
thrownMessage = (err as Error).message;
10291048
}
@@ -1033,10 +1052,10 @@ describe('WORKFLOW_AGENTS environment variable', () => {
10331052

10341053
// And reject agents not in the list
10351054
await expect(
1036-
hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
1037-
sessionID: 'test-session',
1038-
agent: 'other-agent', // Not in list
1039-
} as unknown)
1055+
hooks.tool!['start_development'].execute(
1056+
{ workflow: 'minor' },
1057+
createMockToolContext({ agent: 'other-agent' }) // Not in list
1058+
)
10401059
).rejects.toThrow(/not enabled for this agent/i);
10411060
} finally {
10421061
if (originalEnv === undefined) {

0 commit comments

Comments
 (0)