opencode-codex-security 0.0.0-stage → 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.
Files changed (42) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/DISCLOSURE +19 -0
  3. package/LICENSE +202 -0
  4. package/README.md +103 -2
  5. package/SECURITY.md +22 -0
  6. package/dist/gui-experimental.cjs +17 -0
  7. package/docs/GLOBAL-CONFIG.md +64 -0
  8. package/docs/MULTI-SCAN.md +62 -0
  9. package/docs/PUBLISHING.md +71 -0
  10. package/docs/RELEASING.md +53 -0
  11. package/docs/SDK.md +36 -0
  12. package/fixtures/vulnerable/docs/ARCHITECTURE.md +2 -0
  13. package/fixtures/vulnerable/src/accounts.js +4 -0
  14. package/fixtures/vulnerable/src/diagnostics.js +5 -0
  15. package/fixtures/vulnerable/src/download.js +5 -0
  16. package/fixtures/vulnerable/src/profile.js +4 -0
  17. package/package.json +79 -4
  18. package/src/core/codex-adapter.ts +430 -0
  19. package/src/core/credential-guard.ts +83 -0
  20. package/src/core/planned-scan.ts +316 -0
  21. package/src/core/report-safety.ts +41 -0
  22. package/src/core/result-summary.ts +512 -0
  23. package/src/core/run-history.ts +107 -0
  24. package/src/core/run-telemetry.ts +359 -0
  25. package/src/core/scan-plan.ts +182 -0
  26. package/src/core/scan-service.mjs +129 -0
  27. package/src/core/scanner.mjs +129 -0
  28. package/src/core/sdk-settings.ts +123 -0
  29. package/src/core/security-operations.ts +598 -0
  30. package/src/gui.tsx +102 -0
  31. package/src/operation-tools.ts +247 -0
  32. package/src/plan-view.ts +47 -0
  33. package/src/plugin.ts +346 -0
  34. package/src/rpc.ts +124 -0
  35. package/src/scan-display.ts +284 -0
  36. package/src/security-operations-panel.tsx +551 -0
  37. package/src/security-panel.tsx +1003 -0
  38. package/src/security-styles.ts +10 -0
  39. package/src/security-tui-operations.tsx +343 -0
  40. package/src/state-order.ts +21 -0
  41. package/src/tui.tsx +630 -0
  42. package/src/types.ts +84 -0
@@ -0,0 +1,10 @@
1
+ /** Scoped CSS travels inside the supported single-file GUI bundle. */
2
+ export const securityStyles = `
3
+ .gill-security{--gs-bg:var(--background-base,#151515);--gs-panel:var(--background-raised,#202020);--gs-fg:var(--text-base,#eee);--gs-muted:var(--text-weak,#b5b5b5);--gs-line:var(--border-base,#454545);--gs-accent:#9be4c6;--gs-on-accent:#142b21;color:var(--gs-fg);background:var(--gs-bg);font-size:13px;line-height:1.5;font-family:inherit;height:100%;overflow:auto;container-type:inline-size;scrollbar-color:var(--gs-line) transparent;scrollbar-width:thin}
4
+ .gill-security *{box-sizing:border-box}.gill-security ::selection{background:#35634f;color:#fff}.gill-security button,.gill-security input,.gill-security textarea,.gill-security select{font:inherit;color:inherit}.gill-security button{cursor:pointer}.gill-security button:disabled{opacity:.45;cursor:not-allowed}.gill-security :focus-visible{outline:2px solid var(--gs-accent);outline-offset:3px}.gill-security input,.gill-security textarea{caret-color:var(--gs-accent)}
5
+ .gs-workspace{font-size:12px;color:var(--gs-muted);margin:0 0 20px}.gs-workspace>summary{cursor:pointer}.gs-workspace p{margin:8px 0;overflow-wrap:anywhere}.gs-run-target{font-size:12px;color:var(--gs-muted);margin-top:4px;overflow-wrap:anywhere}.gs-body{padding:24px;max-width:900px;margin:auto}.gs-header{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:24px}.gs-header h2{font-size:20px;font-weight:600;margin:0;letter-spacing:-.025em}.gs-muted{color:var(--gs-muted)}.gs-small{font-size:12px}.gs-header p{margin:3px 0 0;font-size:12px}.gs-button{padding:8px 13px;border:1px solid var(--gs-line);border-radius:6px;background:transparent;line-height:1.4;white-space:nowrap}.gs-button:hover:not(:disabled){background:var(--gs-panel)}.gs-button.gs-primary{background:var(--gs-accent);border-color:transparent;color:var(--gs-on-accent);font-weight:600}.gs-button.gs-primary:hover:not(:disabled){filter:brightness(1.08)}.gs-link{border:0;background:none;padding:4px 0;color:var(--gs-muted)!important;text-decoration:underline;text-underline-offset:3px}.gs-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:16px}.gs-notice{padding:10px 12px;border:1px solid var(--gs-line);border-radius:6px;font-size:12px;margin-bottom:18px}.gs-error{color:#ffb3b3;border-color:#985353;overflow-wrap:anywhere}.gs-form{padding-bottom:24px;margin-bottom:24px;border-bottom:1px solid var(--gs-line)}.gs-form h3,.gs-detail h3{font-size:15px;font-weight:600;margin:0 0 12px}.gs-form label{display:block;font-size:12px;font-weight:500;margin-bottom:14px}.gs-form input,.gs-form textarea,.gs-form select{display:block;width:100%;margin-top:6px;background:var(--gs-panel);border:1px solid var(--gs-line);padding:9px 10px;border-radius:5px;line-height:1.5}.gs-form textarea{resize:vertical;min-height:68px}.gs-form input::placeholder,.gs-form textarea::placeholder{color:var(--gs-muted)}.gs-form .gs-help{display:block;font-weight:400;color:var(--gs-muted);font-size:12px;margin-top:5px}.gs-row{display:flex;align-items:flex-start;gap:12px}.gs-row>*{flex:1;min-width:0}.gs-form details{margin:16px 0}.gs-form summary,.gs-detail summary{cursor:pointer;color:var(--gs-muted);padding:4px 0}.gs-form details[open]>summary,.gs-detail details[open]>summary{margin-bottom:12px}.gs-review{border:1px solid var(--gs-line);padding:16px;border-radius:8px}.gs-review dl,.gs-details-list{margin:0}.gs-review dt,.gs-details-list dt{color:var(--gs-muted);font-size:12px;margin-top:12px}.gs-review dt:first-child,.gs-details-list dt:first-child{margin-top:0}.gs-review dd,.gs-details-list dd{margin:3px 0 0;overflow-wrap:anywhere}.gs-review p{margin:12px 0 0}.gs-review .gs-approval{border-top:1px solid var(--gs-line);padding-top:12px;margin-top:16px;font-size:12px;color:var(--gs-muted)}.gs-section-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}.gs-section-heading h3{font-size:13px;margin:0;font-weight:600}.gs-section-heading span{color:var(--gs-muted);font-size:12px}.gs-runs{margin-bottom:28px;font-size:12px}.gs-runs>summary{cursor:pointer;color:var(--gs-muted);padding:0 0 12px;border-bottom:1px solid var(--gs-line)}.gs-runs>summary span{float:right}.gs-runs[open]>summary{margin-bottom:8px}.gs-run-list{list-style:none;padding:0;margin:0;max-height:240px;overflow:auto;scrollbar-width:thin}.gs-run-list li{border-bottom:1px solid var(--gs-line)}.gs-run-button{display:block;background:none;border:0;border-radius:4px;text-align:left;padding:12px 10px;width:100%;color:inherit}.gs-run-button:hover{background:var(--gs-panel)}.gs-run-button[aria-pressed=true]{background:var(--gs-panel);box-shadow:inset 2px 0 var(--gs-accent)}.gs-run-top{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.gs-run-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.gs-run-meta{font-size:12px;color:var(--gs-muted);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gs-status{font-size:11px;white-space:nowrap;flex-shrink:0;color:var(--gs-muted);text-transform:capitalize}.gs-status[data-status=running]{color:var(--gs-accent)}.gs-status[data-status=error]{color:#ffb3b3}.gs-detail{padding-top:0}.gs-detail-title{margin-bottom:24px}.gs-detail-title h3{margin:0;font-size:14px;line-height:1.5;font-weight:500;overflow-wrap:anywhere}.gs-detail-title p{margin:4px 0 0;overflow-wrap:anywhere;font-size:12px;color:var(--gs-muted)}.gs-progress-heading{display:flex;justify-content:space-between;align-items:baseline;gap:16px;margin:0 0 12px}.gs-progress-heading h3{font-size:24px;line-height:1.25;font-weight:600;letter-spacing:-.025em;margin:0}.gs-progress-heading span{font-size:12px;color:var(--gs-muted);font-variant-numeric:tabular-nums}.gs-progress{appearance:none;width:100%;height:6px;border:0;display:block;margin:16px 0 10px;background:var(--gs-line);border-radius:2px;overflow:hidden}.gs-progress::-webkit-progress-bar{background:var(--gs-line)}.gs-progress::-webkit-progress-value{background:var(--gs-accent)}.gs-progress::-moz-progress-bar{background:var(--gs-accent)}.gs-progress-meta{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;color:var(--gs-muted);font-size:12px}.gs-live-note{margin:24px 0;max-width:64ch;font-size:13px}.gs-scan-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:16px;font-variant-numeric:tabular-nums}.gs-activity{margin:0;font-size:12px}.gs-activity-list{padding:0;margin:8px 0 0;list-style:none;max-height:220px;overflow:auto;scrollbar-width:thin}.gs-activity-list li{display:flex;gap:12px;align-items:baseline;padding:6px 0;font-size:12px;overflow-wrap:anywhere}.gs-activity-list time{color:var(--gs-muted);font-variant-numeric:tabular-nums;white-space:nowrap;flex-shrink:0}.gs-activity-list li:first-child{color:var(--gs-fg)}.gs-findings{margin:28px 0 24px}.gs-findings .gs-section-heading h3{font-size:18px}.gs-findings details{padding:12px 0;border-bottom:1px solid var(--gs-line)}.gs-findings summary{color:var(--gs-fg);padding:0}.gs-severity{font-size:11px;text-transform:uppercase;margin-right:8px;color:#ebc28c}.gs-severity[data-severity=critical],.gs-severity[data-severity=high]{color:#ffb3b3}.gs-findings p{font-size:13px;margin:12px 0}.gs-findings pre{font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere;padding:12px;background:var(--gs-panel);border-radius:5px;max-height:280px;overflow:auto}.gs-finding-location{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--gs-muted);overflow-wrap:anywhere;margin:6px 0}.gs-empty{padding:22px 0;color:var(--gs-muted);font-size:13px}.gs-empty h3{color:var(--gs-fg);margin:0 0 6px;font-size:15px}.gs-empty p{margin:0;max-width:48ch}.gs-footer{font-size:11px;color:var(--gs-muted);margin-top:24px}.gs-advanced{margin-top:0;font-size:12px}.gs-detail>.gs-advanced,.gs-detail>.gs-activity,.gs-coverage{border-top:1px solid var(--gs-line)}.gs-detail>.gs-advanced>summary,.gs-detail>.gs-activity>summary,.gs-coverage>summary{padding:14px 0;color:var(--gs-fg)}.gs-detail>.gs-advanced[open],.gs-detail>.gs-activity[open],.gs-coverage[open]{padding-bottom:16px}.gs-advanced pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.gs-pending{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;margin-bottom:16px;border-bottom:1px solid var(--gs-line);font-size:12px}.gs-workers{font-size:12px;color:var(--gs-muted);padding:0;list-style:none}.gs-workers li{padding:3px 0}.gs-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;overflow-wrap:anywhere}
6
+ .gs-tabs{display:flex;gap:20px;border-bottom:1px solid var(--gs-line);margin:-4px 0 24px}.gs-tabs button{border:0;background:none;padding:8px 0 12px;color:var(--gs-muted);font-weight:500}.gs-tabs button[aria-current=page]{color:var(--gs-fg);border-bottom:2px solid var(--gs-accent)}.gs-check{display:flex!important;align-items:flex-start;gap:8px;font-size:12px!important;margin-top:16px}.gs-check input{width:auto!important;margin-top:4px!important}.gs-questions{margin-top:20px}.gs-questions li{margin-bottom:8px}.gs-operation-result{margin-top:22px}.gs-operation-result pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--gs-panel);padding:14px;border-radius:6px;max-height:440px;overflow:auto;font:12px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace}
7
+ .gs-worker-activity{list-style:none;padding:0;margin:8px 0}.gs-worker-activity li{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:9px 0;border-bottom:1px solid var(--gs-line);font-size:12px}.gs-activity-copy{display:flex;gap:12px;min-width:0;overflow-wrap:anywhere}.gs-activity-actor{color:var(--gs-muted);flex-shrink:0;min-width:78px}.gs-activity-state{color:var(--gs-muted);font-size:11px;text-align:right;flex-shrink:0}.gs-activity-state[data-status=failed]{color:#ffb3b3}.gs-activity-history{margin-top:12px;font-size:12px}.gs-activity-paths{display:block;color:var(--gs-muted);font-size:11px;overflow-wrap:anywhere}.gs-outcome{margin-top:24px}.gs-outcome-summary{font-size:12px;color:var(--gs-muted);margin:0 0 16px}.gs-coverage{font-size:12px;margin:0}.gs-coverage ul{padding-left:18px;margin:8px 0}.gs-coverage li{margin:5px 0}
8
+ @container(max-width:440px){.gs-body{padding:18px 16px}.gs-row{display:block}.gs-run-top{gap:8px}.gs-header{gap:10px}.gs-header h2{font-size:18px}.gs-run-title{max-width:65%}.gs-activity-copy{flex-direction:column;gap:2px}.gs-worker-activity li{gap:8px}.gs-activity-state{max-width:110px}.gs-progress-heading{align-items:flex-start}.gs-progress-heading h3{font-size:22px}.gs-progress-heading span{white-space:nowrap}}
9
+ @media(prefers-reduced-motion:no-preference){.gs-detail{animation:gs-appear .15s ease-out}@keyframes gs-appear{from{opacity:.85}to{opacity:1}}}
10
+ `;
@@ -0,0 +1,343 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { Plugin } from '@opencode/plugin/tui';
3
+ type Context = Plugin.Context;
4
+ type Commands = NonNullable<ReturnType<Parameters<Context['keymap']['layer']>[0]>['commands']>;
5
+ import { createSignal, For, Show } from 'solid-js';
6
+ import { Security } from './rpc';
7
+ import type { SecurityOperationState } from './core/security-operations';
8
+ import type { ScanState } from './types';
9
+ import { findingText, phaseLabel, userError } from './scan-display';
10
+
11
+ export function createTuiOperations(
12
+ ctx: Context,
13
+ location: { directory: string },
14
+ scan: () => ScanState | undefined,
15
+ open: () => void,
16
+ chooseTarget: () => Promise<string | undefined>,
17
+ ) {
18
+ const client = ctx.client.rpc(Security);
19
+ const [operations, setOperations] = createSignal<SecurityOperationState[]>([]);
20
+ const [selected, select] = createSignal<string>();
21
+ const [visible, setVisible] = createSignal(false);
22
+ const [busy, setBusy] = createSignal(false);
23
+ const [error, setError] = createSignal('');
24
+ const [capabilities, setCapabilities] = createSignal({
25
+ policy: false,
26
+ validation: false,
27
+ questionAnswers: false,
28
+ });
29
+ let stopped = false;
30
+ const current = () => operations().find((op) => op.operationId === selected()) ?? operations()[0];
31
+ const refresh = async () => {
32
+ if (stopped || (!capabilities().policy && !capabilities().validation)) return;
33
+ try {
34
+ const result = (await client.operationList({}, { location })) as unknown as {
35
+ operations: SecurityOperationState[];
36
+ };
37
+ if (!stopped) setOperations(result.operations);
38
+ } catch (e) {
39
+ if (!stopped && visible()) setError(userError(e));
40
+ }
41
+ };
42
+ void client
43
+ .capabilities({}, { location })
44
+ .then((value) => {
45
+ if (!stopped)
46
+ setCapabilities(value as unknown as typeof capabilities extends () => infer T ? T : never);
47
+ })
48
+ .catch(() => {});
49
+ const perform = async (action: () => Promise<unknown>) => {
50
+ if (stopped || busy()) return;
51
+ setBusy(true);
52
+ setError('');
53
+ try {
54
+ await action();
55
+ if (!stopped) await refresh();
56
+ } catch (e) {
57
+ if (!stopped) setError(userError(e));
58
+ } finally {
59
+ if (!stopped) setBusy(false);
60
+ }
61
+ };
62
+ const show = () => {
63
+ setVisible(true);
64
+ open();
65
+ };
66
+ const approve = async (op: SecurityOperationState) => {
67
+ const confirmed = await ctx.ui.dialog.confirm({
68
+ title: op.kind === 'policy' ? 'Review policy generation' : 'Review source validation',
69
+ message: `Repository: ${op.repository}\n${op.plan.path ? 'Component: ' + op.plan.path + '\n' : ''}Context: ${op.plan.contextFiles.map((file) => file.path).join(', ') || 'No additional documents'}\n\n${op.plan.mock ? 'Mock operation: no model calls or uploads.' : `Repository source, the selected finding if any, and listed context go to OpenAI.\n${op.plan.costNotice}`}\n\n${op.kind === 'policy' ? 'Returns a draft only. Existing files stay unchanged.' : 'Static source assessment only. No exploit execution or patch application.'}\n\nModel: ${op.plan.settings.codex.model} · ${op.plan.settings.codex.reasoningEffort} reasoning`,
70
+ label: {
71
+ confirm: op.plan.mock
72
+ ? 'Start mock operation'
73
+ : op.kind === 'validation'
74
+ ? 'Approve without cost ceiling'
75
+ : 'Approve and start',
76
+ cancel: 'Back',
77
+ },
78
+ });
79
+ if (!confirmed || stopped) return;
80
+ await client.operationStart(
81
+ {
82
+ operationId: op.operationId,
83
+ approveOpenAIInference: true,
84
+ ...(op.kind === 'validation' ? { approveUncappedValidation: true } : {}),
85
+ },
86
+ { location },
87
+ );
88
+ };
89
+ const commands: Commands = [
90
+ {
91
+ id: 'security.policy',
92
+ title: 'Security: draft a policy',
93
+ group: 'Security',
94
+ palette: true,
95
+ enabled: () => capabilities().policy,
96
+ slash: { name: 'security-policy' },
97
+ run: async () => {
98
+ show();
99
+ await perform(async () => {
100
+ const repository = await chooseTarget();
101
+ if (!repository || stopped) return;
102
+ const path = await ctx.ui.dialog.prompt({
103
+ title: 'Policy component',
104
+ description: `Target: ${repository}. Repository-relative directory, or leave blank for the whole project. Preparation makes no model calls.`,
105
+ placeholder: 'src/api',
106
+ });
107
+ if (path === undefined || stopped) return;
108
+ const op = (await client.operationPrepare(
109
+ { kind: 'policy', repository, ...(path.trim() ? { path: path.trim() } : {}) },
110
+ { location },
111
+ )) as unknown as SecurityOperationState;
112
+ select(op.operationId);
113
+ await refresh();
114
+ await approve(op);
115
+ });
116
+ },
117
+ },
118
+ {
119
+ id: 'security.validate',
120
+ title: 'Security: validate a finding',
121
+ group: 'Security',
122
+ palette: true,
123
+ enabled: () => capabilities().validation,
124
+ slash: { name: 'security-validate' },
125
+ run: async () => {
126
+ show();
127
+ await perform(async () => {
128
+ const source = scan();
129
+ let finding: string | undefined;
130
+ let repository: string | undefined;
131
+ if (source?.findings.length) {
132
+ const id = await ctx.ui.dialog.select({
133
+ title: `Finding to validate · ${source.repository}`,
134
+ options: source.findings.map((f) => ({
135
+ title: `${f.severity.toUpperCase()} ${f.title}`,
136
+ description: `${f.path}:${f.line}`,
137
+ value: f.id,
138
+ })),
139
+ });
140
+ if (!id) return;
141
+ finding = findingText(source.findings.find((f) => f.id === id)!);
142
+ repository = source.repository;
143
+ } else {
144
+ repository = await chooseTarget();
145
+ if (!repository || stopped) return;
146
+ finding = await ctx.ui.dialog.prompt({
147
+ title: 'Finding to validate',
148
+ description: `Target: ${repository}. Describe the suspected issue, source location and evidence. Do not include secrets. Validation has no SDK cost ceiling.`,
149
+ });
150
+ }
151
+ if (!finding?.trim() || stopped) return;
152
+ const op = (await client.operationPrepare(
153
+ { kind: 'validation', repository, finding },
154
+ { location },
155
+ )) as unknown as SecurityOperationState;
156
+ select(op.operationId);
157
+ await refresh();
158
+ await approve(op);
159
+ });
160
+ },
161
+ },
162
+ {
163
+ id: 'security.operations',
164
+ title: 'Security: choose policy or validation work',
165
+ group: 'Security',
166
+ palette: true,
167
+ enabled: () => capabilities().policy || capabilities().validation,
168
+ slash: { name: 'security-operations' },
169
+ run: async () => {
170
+ show();
171
+ await refresh();
172
+ if (!operations().length) {
173
+ await ctx.ui.dialog.alert({
174
+ title: 'Security operations',
175
+ message:
176
+ 'No policy or validation work yet. Use /security-policy or /security-validate to prepare one.',
177
+ });
178
+ return;
179
+ }
180
+ const id = await ctx.ui.dialog.select({
181
+ title: 'Choose an operation',
182
+ current: current()?.operationId,
183
+ options: operations().map((op) => ({
184
+ title: `${op.kind === 'policy' ? 'Policy' : 'Validation'} · ${phaseLabel(op.status)}`,
185
+ description: `${op.repository} · ${op.operationId.slice(0, 8)}`,
186
+ value: op.operationId,
187
+ })),
188
+ });
189
+ if (id && !stopped) select(id);
190
+ },
191
+ },
192
+ {
193
+ id: 'security.operationStart',
194
+ title: 'Security: review selected prepared operation',
195
+ group: 'Security',
196
+ palette: true,
197
+ enabled: () => current()?.status === 'prepared',
198
+ slash: { name: 'security-operation-start' },
199
+ run: async () => {
200
+ const op = current();
201
+ if (op) await perform(() => approve(op));
202
+ },
203
+ },
204
+ {
205
+ id: 'security.answer',
206
+ title: 'Security: answer policy questions',
207
+ group: 'Security',
208
+ palette: true,
209
+ enabled: () => !!current()?.question,
210
+ slash: { name: 'security-answer' },
211
+ run: async () => {
212
+ const op = current(),
213
+ question = op?.question;
214
+ if (!op || !question) return;
215
+ show();
216
+ await perform(async () => {
217
+ const answer = await ctx.ui.dialog.prompt({
218
+ title: 'Answer security questions',
219
+ description:
220
+ question.questions.map((q, index) => `${index + 1}. ${q}`).join('\n') +
221
+ '\n\nAnswer each question or state what is unknown. Your answer will be sent to OpenAI for this operation; do not include secrets.',
222
+ });
223
+ if (!answer?.trim() || stopped) return;
224
+ await client.operationAnswer(
225
+ { operationId: op.operationId, questionId: question.id, answer },
226
+ { location },
227
+ );
228
+ });
229
+ },
230
+ },
231
+ {
232
+ id: 'security.operationCancel',
233
+ title: 'Security: cancel selected operation',
234
+ group: 'Security',
235
+ palette: true,
236
+ enabled: () => ['prepared', 'running', 'awaiting_answers'].includes(current()?.status ?? ''),
237
+ slash: { name: 'security-operation-cancel' },
238
+ run: async () => {
239
+ const op = current();
240
+ if (op)
241
+ await perform(() =>
242
+ client.operationCancel({ operationId: op.operationId }, { location }),
243
+ );
244
+ },
245
+ },
246
+ {
247
+ id: 'security.operationResult',
248
+ title: 'Security: read operation result',
249
+ group: 'Security',
250
+ palette: true,
251
+ enabled: () => !!current()?.result,
252
+ slash: { name: 'security-operation-result' },
253
+ run: async () => {
254
+ const op = current(),
255
+ result = op?.result;
256
+ if (!result) return;
257
+ await ctx.ui.dialog.alert({
258
+ title:
259
+ result.kind === 'policy'
260
+ ? 'Policy draft · not applied'
261
+ : `Source assessment · ${phaseLabel(result.disposition)}`,
262
+ message:
263
+ (result.kind === 'policy'
264
+ ? result.content + '\n\nReview notes:\n' + result.reviewNotes.join('\n')
265
+ : result.report) +
266
+ '\n\n' +
267
+ (result.truncated
268
+ ? 'Preview truncated. Read full local SDK artifacts.'
269
+ : result.kind === 'policy'
270
+ ? 'No repository files changed.'
271
+ : 'Static assessment, not runtime reproduction.'),
272
+ });
273
+ },
274
+ },
275
+ ];
276
+ const view = () => (
277
+ <box flexDirection="column" gap={1}>
278
+ <text>/security-operations: choose work · /security: return to scans</text>
279
+ <Show when={error()}>
280
+ <text fg="#ffb3b3">{error()}</text>
281
+ </Show>
282
+ <Show
283
+ when={current()}
284
+ fallback={
285
+ <text>
286
+ Prepare a policy draft with /security-policy or assess a finding with
287
+ /security-validate.
288
+ </text>
289
+ }
290
+ >
291
+ {(op) => (
292
+ <box flexDirection="column" gap={1}>
293
+ <text fg="#9be4c6">
294
+ {op().kind === 'policy' ? 'Policy draft' : 'Finding validation'} ·{' '}
295
+ {phaseLabel(op().status)}
296
+ </text>
297
+ <text>Target: {op().repository}</text>
298
+ <text>{op().phase}</text>
299
+ <Show when={op().status === 'prepared'}>
300
+ <text>/security-operation-start to review this operation before starting.</text>
301
+ </Show>
302
+ <Show when={op().question}>
303
+ {(q) => (
304
+ <box flexDirection="column">
305
+ <text>Your input is needed. /security-answer to respond.</text>
306
+ <For each={q().questions}>{(text) => <text>{text}</text>}</For>
307
+ </box>
308
+ )}
309
+ </Show>
310
+ <Show when={op().estimatedCostUsd !== undefined}>
311
+ <text>Estimated usage: USD {(op().estimatedCostUsd ?? 0).toFixed(2)}</text>
312
+ </Show>
313
+ <Show when={op().error}>
314
+ <text fg="#ffb3b3">{op().error}</text>
315
+ </Show>
316
+ <Show when={op().result}>
317
+ <text>
318
+ /security-operation-result to read{' '}
319
+ {op().kind === 'policy'
320
+ ? 'the draft. No files have been applied.'
321
+ : 'the static assessment. This is not runtime reproduction.'}
322
+ </text>
323
+ </Show>
324
+ <Show when={op().status === 'running' || op().status === 'awaiting_answers'}>
325
+ <text>/security-operation-cancel stops only this operation.</text>
326
+ </Show>
327
+ </box>
328
+ )}
329
+ </Show>
330
+ </box>
331
+ );
332
+ return {
333
+ commands,
334
+ view,
335
+ visible,
336
+ close: () => setVisible(false),
337
+ refresh,
338
+ questionCount: () => operations().filter((op) => op.status === 'awaiting_answers').length,
339
+ dispose: () => {
340
+ stopped = true;
341
+ },
342
+ };
343
+ }
@@ -0,0 +1,21 @@
1
+ import type { ScanState } from './types.js';
2
+ /** Revisions are scoped to one server instance, never compared across restarts. */
3
+ export class SnapshotOrder {
4
+ #instance = '';
5
+ #revision = -1;
6
+ #retired = new Set<string>();
7
+ accept(next: ScanState, authoritative = false): boolean {
8
+ if (!next.instanceId || this.#retired.has(next.instanceId)) return false;
9
+ if (next.instanceId !== this.#instance) {
10
+ // Only an RPC snapshot establishes a new epoch. Late/disconnected events
11
+ // cannot replace a current instance; the next poll reconciles new events.
12
+ if (!authoritative && this.#instance) return false;
13
+ if (this.#instance) this.#retired.add(this.#instance);
14
+ this.#instance = next.instanceId;
15
+ this.#revision = -1;
16
+ }
17
+ if (next.revision < this.#revision) return false;
18
+ this.#revision = next.revision;
19
+ return true;
20
+ }
21
+ }