initial chat feature

This commit is contained in:
its-a-feature
2026-05-21 22:26:17 -07:00
parent 7d6249f153
commit 2c25e31dbf
31 changed files with 6616 additions and 34 deletions
+1463 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -50,6 +50,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
"react-markdown": "^10.1.0",
"react-moment": "^1.1.3",
"react-router-dom": "^7.13.1",
"react-scrollbar-size": "^5.0.0",
@@ -59,6 +60,7 @@
"react-virtualized": "^9.22.6",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"remark-gfm": "^4.0.1",
"save-svg-as-png": "^1.4.17",
"semver": "^7.6.2",
"sql.js": "^1.13.0",
+26 -6
View File
@@ -77,7 +77,7 @@ export const taskingContextFieldsOptions = ["impersonation_context", "cwd", "use
export const defaultShortcuts = [
"ActiveCallbacks", "Payloads", "PayloadTypesAndC2",
"Operations", "SearchFiles", "SearchProxies",
"CreatePayload", "Eventing",
"CreatePayload", "Eventing", "Chat",
].sort();
export const operatorSettingDefaults = {
fontSize: 13,
@@ -154,6 +154,30 @@ export const operatorSettingDefaults = {
dark: "#e5e7eb",
light: "#111827",
},
chatMessageOperatorBackground: {
dark: "#18191c",
light: "#ffffff",
},
chatMessageSelfBackground: {
dark: "#202123",
light: "#f2f3f2",
},
chatMessageAIBackground: {
dark: "#232220",
light: "#f3f2ef",
},
chatMessageSystemBackground: {
dark: "#252320",
light: "#f7f3eb",
},
chatMarkdownSurfaceBackground: {
dark: "#17181a",
light: "#f3f4f5",
},
chatMarkdownSurfaceStrongBackground: {
dark: "#2b2c2f",
light: "#e8e9eb",
},
sectionHeaderAccent: {
dark: "#8ab4f8",
light: "#2563eb",
@@ -182,10 +206,6 @@ export const operatorSettingDefaults = {
dark: "#394c5d",
light: "#d3d7e8",
},
speedDialAction: {
dark: "#495054",
light: "#ffffff",
},
chartSeries1: {
dark: "#09bdff",
light: "#09bdff",
@@ -338,7 +358,7 @@ export const successfulRefresh = (data) => {
let now = new Date();
let serverNow = new Date(data.user.current_utc_time);
const difference = (serverNow.getTime() - now.getTime()) ;
let me = {...meState().user};
let me = {...meState().user, ...(data.user || {})};
me.server_skew = difference;
me.login_time = now;
meState({
+13 -4
View File
@@ -46,6 +46,7 @@ const Reporting = lazyNamed(() => import('./pages/Reporting/Reporting'), 'Report
const MitreAttack = lazyNamed(() => import('./pages/MITRE_ATTACK/MitreAttack'), 'MitreAttack');
const Tags = lazyNamed(() => import('./pages/Tags/Tags'), 'Tags');
const Eventing = lazyNamed(() => import('./pages/Eventing/Eventing'), 'Eventing');
const Chat = lazyNamed(() => import('./pages/Chat/Chat'), 'Chat');
const Jupyter = lazyNamed(() => import('./pages/Jupyter/Jupyter'), 'Jupyter');
const Hasura = lazyNamed(() => import('./pages/Hasura/Hasura'), 'Hasura');
@@ -163,6 +164,16 @@ const getModernThemeAdditions = (themeMode, preferences = operatorSettingDefault
hover: tableRowHoverColor,
selected: tableSelectedColor,
},
chat: {
message: {
operatorBackground: getColor("chatMessageOperatorBackground"),
selfBackground: getColor("chatMessageSelfBackground"),
aiBackground: getColor("chatMessageAIBackground"),
systemBackground: getColor("chatMessageSystemBackground"),
markdownSurface: getColor("chatMarkdownSurfaceBackground"),
markdownSurfaceStrong: getColor("chatMarkdownSurfaceStrongBackground"),
},
},
table: {
header: tableHeaderColor,
headerHover: tableRowHoverColor,
@@ -709,8 +720,6 @@ export function App(props) {
preferences?.palette?.borderColor?.light || operatorSettingDefaults.palette.borderColor.light,
graphGroupRGBA: themeMode === 'dark' ? `${preferences?.palette?.graphGroupColor?.dark || operatorSettingDefaults.palette.graphGroupColor.dark}80` :
`${preferences?.palette?.graphGroupColor?.light || operatorSettingDefaults.palette.graphGroupColor.light}80`,
speedDialAction: themeMode === 'dark' ? preferences?.palette?.speedDialAction?.dark || operatorSettingDefaults.palette.speedDialAction.dark :
preferences?.palette?.speedDialAction?.light || operatorSettingDefaults.palette.speedDialAction.light,
},
folderColor: themeMode === 'dark' ? preferences?.palette?.folderColor?.dark || operatorSettingDefaults.palette.folderColor.dark :
preferences?.palette?.folderColor?.light || operatorSettingDefaults.palette.folderColor.light,
@@ -829,8 +838,6 @@ export function App(props) {
operatorSettingDefaults.palette.borderColor.light,
graphGroupRGBA: themeMode === 'dark' ? `${operatorSettingDefaults.palette.graphGroupColor.dark}80` :
`${operatorSettingDefaults.palette.graphGroupColor.light}80`,
speedDialAction: themeMode === 'dark' ? operatorSettingDefaults.palette.speedDialAction.dark :
operatorSettingDefaults.palette.speedDialAction.light,
},
folderColor: themeMode === 'dark' ? operatorSettingDefaults.palette.folderColor.dark :
operatorSettingDefaults.palette.folderColor.light,
@@ -1071,6 +1078,8 @@ export function App(props) {
element={<LoggedInRoute me={me}><Tags me={me}/></LoggedInRoute>}/>
<Route exact path='/new/eventing'
element={<LoggedInRoute me={me}><Eventing me={me}/></LoggedInRoute>}/>
<Route exact path='/new/chat'
element={<LoggedInRoute me={me}><Chat me={me}/></LoggedInRoute>}/>
<Route exact path='/new/jupyter' element={<LoggedInRoute me={me}><Jupyter/></LoggedInRoute>}/>
<Route exact path='/new/hasura' element={<LoggedInRoute me={me}><Hasura/></LoggedInRoute>}/>
</Routes>
@@ -10,6 +10,7 @@ import ManageAccountsTwoToneIcon from '@mui/icons-material/ManageAccountsTwoTone
import { Link } from 'react-router-dom';
import List from '@mui/material/List';
import Divider from '@mui/material/Divider';
import Badge from '@mui/material/Badge';
import SportsScoreIcon from '@mui/icons-material/SportsScore';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
@@ -47,8 +48,9 @@ import LocalOfferTwoToneIcon from '@mui/icons-material/LocalOfferTwoTone';
import LightModeTwoToneIcon from '@mui/icons-material/LightModeTwoTone';
import DarkModeTwoToneIcon from '@mui/icons-material/DarkModeTwoTone';
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
import ForumTwoToneIcon from '@mui/icons-material/ForumTwoTone';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import {useQuery, gql} from '@apollo/client';
import {useQuery, useSubscription, gql} from '@apollo/client';
import ConfirmationNumberIcon from '@mui/icons-material/ConfirmationNumber';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import AssignmentIcon from '@mui/icons-material/Assignment';
@@ -74,6 +76,7 @@ import {reorder} from "./MythicComponents/MythicDraggableList";
import { useNavigate } from 'react-router-dom';
import LogoutIcon from '@mui/icons-material/Logout';
import TuneIcon from '@mui/icons-material/Tune';
import {getSkewedNow} from "./utilities/Time";
import {
MythicDialogBody,
MythicDialogButton,
@@ -241,6 +244,79 @@ query getGlobalSettings {
}
}
`;
const CHAT_UNREAD_STATUS_QUERY = gql`
query ChatUnreadStatus {
chat_channel(where: {last_message_id: {_is_null: false}}) {
id
archived
last_message_id
updated_at
}
chat_read_state {
channel_id
last_read_message_id
updated_at
}
}
`;
const CHAT_UNREAD_CHANNELS_STREAM_SUBSCRIPTION = gql`
subscription ChatUnreadChannelsStream($now: timestamp!) {
chat_channel_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}, where: {last_message_id: {_is_null: false}}) {
id
archived
last_message_id
updated_at
}
}
`;
const CHAT_UNREAD_READ_STATE_STREAM_SUBSCRIPTION = gql`
subscription ChatUnreadReadStateStream($now: timestamp!) {
chat_read_state_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}) {
channel_id
last_read_message_id
updated_at
}
}
`;
const unreadTimestampValue = (timestamp) => {
if(!timestamp){ return 0; }
const value = new Date(timestamp).getTime();
return Number.isNaN(value) ? 0 : value;
};
const mergeUnreadChannels = (current, incoming) => {
if(!incoming || incoming.length === 0){ return current; }
const rowsByID = new Map((current || []).map((channel) => [channel.id, channel]));
incoming.forEach((channel) => {
const existing = rowsByID.get(channel.id);
if(!existing || unreadTimestampValue(channel.updated_at) >= unreadTimestampValue(existing.updated_at)){
rowsByID.set(channel.id, {...existing, ...channel});
}
});
return [...rowsByID.values()];
};
const mergeUnreadReadStates = (current, incoming) => {
if(!incoming || incoming.length === 0){ return current; }
return incoming.reduce((prev, readState) => ({
...prev,
[readState.channel_id]: Math.max(prev[readState.channel_id] || 0, readState.last_read_message_id || 0),
}), current);
};
const getUnreadChatCount = (channels, readState) => {
return (channels || []).reduce((count, channel) => {
if(channel.archived){ return count; }
const latestMessageID = channel.last_message_id || 0;
const lastReadMessageID = readState[channel.id] || 0;
return latestMessageID > lastReadMessageID ? count + 1 : count;
}, 0);
};
const Dashboard = () => {
const theme = useTheme();
return (
@@ -492,6 +568,80 @@ const Eventing = () => {
</StyledListItem>
)
}
const Chat = ({me}) => {
const theme = useTheme();
const streamStart = React.useRef(getSkewedNow().toISOString());
const [channels, setChannels] = React.useState([]);
const [readState, setReadState] = React.useState({});
const {data: initialData, error: initialError} = useQuery(CHAT_UNREAD_STATUS_QUERY, {
skip: !me?.user?.current_operation_id,
fetchPolicy: "no-cache",
});
React.useEffect(() => {
streamStart.current = getSkewedNow().toISOString();
setChannels([]);
setReadState({});
}, [me?.user?.current_operation_id]);
React.useEffect(() => {
if(initialData?.chat_channel){
setChannels((prev) => mergeUnreadChannels(prev, initialData.chat_channel));
}
if(initialData?.chat_read_state){
setReadState((prev) => mergeUnreadReadStates(prev, initialData.chat_read_state));
}
}, [initialData]);
const {error: channelStreamError} = useSubscription(CHAT_UNREAD_CHANNELS_STREAM_SUBSCRIPTION, {
variables: {now: streamStart.current},
skip: !me?.user?.current_operation_id,
fetchPolicy: "no-cache",
onData: ({data}) => {
const updates = data.data?.chat_channel_stream || [];
if(updates.length > 0){
setChannels((prev) => mergeUnreadChannels(prev, updates));
}
},
onError: (errorData) => {
console.log("chat unread channel stream error");
console.error(errorData);
},
});
const {error: readStateStreamError} = useSubscription(CHAT_UNREAD_READ_STATE_STREAM_SUBSCRIPTION, {
variables: {now: streamStart.current},
skip: !me?.user?.current_operation_id,
fetchPolicy: "no-cache",
onData: ({data}) => {
const updates = data.data?.chat_read_state_stream || [];
if(updates.length > 0){
setReadState((prev) => mergeUnreadReadStates(prev, updates));
}
},
onError: (errorData) => {
console.log("chat unread status error");
console.error(errorData);
},
});
const error = initialError || channelStreamError || readStateStreamError;
const unreadCount = React.useMemo(() => getUnreadChatCount(channels, readState), [channels, readState]);
const tooltipTitle = error ? "Operation Chat unread status unavailable" :
unreadCount > 0 ? `Operation Chat (${unreadCount} unread ${unreadCount === 1 ? "chat" : "chats"})` : "Operation Chat";
return (
<StyledListItem className={classes.listSubHeader} component={Link} to='/new/chat' >
<StyledListItemIcon>
<MythicStyledTooltip title={tooltipTitle} tooltipStyle={{display: "inline-flex"}}>
<Badge
badgeContent={error ? "X" : unreadCount}
color={error ? "secondary" : "error"}
invisible={!error && unreadCount === 0}
max={99}
>
<ForumTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} className="mythicElement"/>
</Badge>
</MythicStyledTooltip>
</StyledListItemIcon>
<ListItemText primary={"Operation Chat"} />
</StyledListItem>
)
}
const JupyterNotebook = () => {
return (
<StyledListItem className={classes.listSubHeader} target="_blank" component={Link} to='/jupyter' key={"jupyter"} >
@@ -586,7 +736,7 @@ const BrowserScripts = () => {
const AllSettingOptions = [
"Dashboard", "ActiveCallbacks", "Payloads", "SearchCallbacks", "SearchTasks", "SearchPayloads",
"SearchFiles", "SearchScreenshots", "SearchCredentials", "SearchKeylogs", "SearchArtifacts", "SearchTokens", "SearchProxies",
"SearchProcesses", "SearchTags", "Mitre", "Reporting", "Tags", "Eventing", "JupyterNotebook",
"SearchProcesses", "SearchTags", "Mitre", "Reporting", "Tags", "Eventing", "Chat", "JupyterNotebook",
"GraphQL", "CreatePayload", "CreateWrapper", "PayloadTypesAndC2", "Operations",
"BrowserScripts"
].sort();
@@ -789,6 +939,8 @@ export function TopAppBarVertical(props) {
return <Tags key={c + i} />
case "Eventing":
return <Eventing key={c + i} />
case "Chat":
return <Chat key={c + i} me={me} />
case "JupyterNotebook":
return <JupyterNotebook key={c + i} />
case "GraphQL":
File diff suppressed because it is too large Load Diff
@@ -141,6 +141,7 @@ export const ConsumingServicesTableRow = ({service, showDeleted}) => {
}
break;
case "auth":
case "chat":
try{
const newSubs = service.subscriptions.map( s => {
try{
@@ -300,6 +301,17 @@ export const ConsumingServicesTableRow = ({service, showDeleted}) => {
{label: "Identity Providers", value: getSubscriptionNames(w), render: renderIdentityProviderMetadata(w)},
],
});
case "chat":
return renderBaseRow({
w,
typeLabel: "Chat",
metadataItems: [
{label: "Type", value: w.type},
{label: "Version", value: w.semver, chip: true},
{label: "Models", value: getSubscriptionNames(w)},
],
hasDetails: true,
});
default:
return null;
}
@@ -320,6 +332,18 @@ export const ConsumingServicesTableRow = ({service, showDeleted}) => {
);
case "auth":
return null;
case "chat":
return (
<InstalledServiceDetailSection title="Chat models" count={(w.subscriptions || []).length}>
<InstalledServiceDefinitionList
items={(w.subscriptions || []).map((subscription) => ({
title: subscription.name || subscription,
description: subscription.description || subscription.type || "",
}))}
emptyText="No chat models registered."
/>
</InstalledServiceDetailSection>
);
default:
return null;
}
@@ -135,7 +135,7 @@ function useCustomSubscription({customSubscription, dataKey}){
return customData
}
const tabTypes = ["Payload Types", "C2 Profiles", "Translators", "Command Augmentation", "3rd Party", "Webhooks", "Loggers", "Eventing", "Auth", "Browsers"];
const tabTypes = ["Payload Types", "C2 Profiles", "Translators", "Command Augmentation", "3rd Party", "Webhooks", "Loggers", "Eventing", "Auth", "Chat", "Browsers"];
const filterDeleted = (c, showDeleted) => {
if(showDeleted){
@@ -197,6 +197,8 @@ export function PayloadTypesC2Profiles({me}){
return visibleData.filter(c => c.__typename === "consuming_container" && c.type === "eventing");
case "Auth":
return visibleData.filter(c => c.__typename === "consuming_container" && c.type === "auth");
case "Chat":
return visibleData.filter(c => c.__typename === "consuming_container" && c.type === "chat");
case "Browsers":
return visibleData.filter(c => c.__typename === "custombrowser");
default:
@@ -235,6 +237,9 @@ export function PayloadTypesC2Profiles({me}){
return <ContainersTabConsumingServicesPanel key={"Auth"} type={"Auth"} index={value} value={value} showDeleted={showDeleted}
containers={getContainersForTab("Auth")} />
case 9:
return <ContainersTabConsumingServicesPanel key={"Chat"} type={"Chat"} index={value} value={value} showDeleted={showDeleted}
containers={getContainersForTab("Chat")} />
case 10:
return <ContainersTabCustomBrowsersPanel key={"Browsers"} type={"Browsers"} index={value} value={value} showDeleted={showDeleted}
containers={getContainersForTab("Browsers")} />
default:
@@ -314,6 +319,9 @@ export function PayloadTypesC2Profiles({me}){
case "Auth":
return <ContainersTabAuthLabel key={"auth"}
containers={getContainersForTab("Auth")}/>;
case "Chat":
return <ContainersTabChatLabel key={"chat"}
containers={getContainersForTab("Chat")}/>;
case "Browsers":
return <ContainersTabBrowsersLabel key={"browsers"}
containers={getContainersForTab("Browsers")}/>;
@@ -399,6 +407,13 @@ const ContainersTabAuthLabel = (props) => {
} {...props}/>
)
}
const ContainersTabChatLabel = (props) => {
return (
<MythicSearchTabLabel label={"Chat" + (props.containers.length > 0 ? " (" + props.containers.length + ") " : "")} iconComponent={
<></>
} {...props}/>
)
}
const ContainersTabCommandAugmentLabel = (props) => {
return (
<MythicSearchTabLabel label={"Command Augments" + (props.containers.length > 0 ? " (" + props.containers.length + ") " : "")} iconComponent={
@@ -515,6 +530,9 @@ You can easily create your own containers, or you can use github.com/MythicAgent
message = `Mythic supports basic username/password authentication to users within its database.
You can extend this auth capability to support your own LDAP, SSO, or otherwise customized authentication flow with auth containers.`;
break;
case "Chat":
message = `Chat containers connect operation chat channels to AI chat services.`;
break;
}
return (
<div style={{overflowY: "hidden", flexGrow: 1}}>
@@ -294,11 +294,22 @@ const COLOR_EDITOR_SECTIONS = [
],
},
{
title: "Graphs and Floating Controls",
description: "Graph grouping surfaces and legacy floating action controls.",
title: "Chat",
description: "Chat message bubbles and markdown block surfaces.",
colors: [
{name: "chatMessageOperatorBackground", display: "Other Messages", description: "Message bubble background for operator messages from other users.", preview: "chat"},
{name: "chatMessageSelfBackground", display: "My Messages", description: "Message bubble background for messages sent by the current operator.", preview: "chat"},
{name: "chatMessageAIBackground", display: "AI Messages", description: "Message bubble background for AI-generated messages.", preview: "chat"},
{name: "chatMessageSystemBackground", display: "System Messages", description: "Message bubble background for chat system messages.", preview: "chat"},
{name: "chatMarkdownSurfaceBackground", display: "Markdown Surface", description: "Background for inline code, fenced code blocks, blockquotes, and markdown table headers.", preview: "chat"},
{name: "chatMarkdownSurfaceStrongBackground", display: "Markdown Label Surface", description: "Background for compact markdown labels such as fenced-code language tags.", preview: "chat"},
],
},
{
title: "Graphs",
description: "Graph grouping surfaces.",
colors: [
{name: "graphGroupColor", display: "Graph Group", description: "Grouped node backgrounds in graph-style views.", preview: "graph"},
{name: "speedDialAction", display: "Floating Action", description: "Floating action buttons where SpeedDial controls are still used.", preview: "floatingAction"},
],
},
{
@@ -517,7 +528,6 @@ const ColorUsagePreview = ({option, palette, mode}) => {
const subtleAccentGradientStart = getPaletteValue(palette, "subtleAccentGradientStart", mode);
const subtleAccentGradientEnd = getPaletteValue(palette, "subtleAccentGradientEnd", mode);
const graphGroup = getPaletteValue(palette, "graphGroupColor", mode);
const speedDialAction = getPaletteValue(palette, "speedDialAction", mode);
const chartSeriesColors = Array.from({length: 10}, (_, index) => getPaletteValue(palette, `chartSeries${index + 1}`, mode));
const navTop = getPaletteValue(palette, "navBarColor", mode);
const navBottom = getPaletteValue(palette, "navBarBottomColor", mode);
@@ -536,6 +546,12 @@ const ColorUsagePreview = ({option, palette, mode}) => {
const extra = getPaletteValue(palette, "taskContextExtraColor", mode);
const folder = getPaletteValue(palette, "folderColor", mode);
const emptyFolder = getPaletteValue(palette, "emptyFolderColor", mode);
const chatOperatorBackground = getPaletteValue(palette, "chatMessageOperatorBackground", mode);
const chatSelfBackground = getPaletteValue(palette, "chatMessageSelfBackground", mode);
const chatAIBackground = getPaletteValue(palette, "chatMessageAIBackground", mode);
const chatSystemBackground = getPaletteValue(palette, "chatMessageSystemBackground", mode);
const chatMarkdownSurface = getPaletteValue(palette, "chatMarkdownSurfaceBackground", mode);
const chatMarkdownSurfaceStrong = getPaletteValue(palette, "chatMarkdownSurfaceStrongBackground", mode);
const previewColor = getPaletteValue(palette, option.name, mode);
const shellSx = {
border: `1px solid ${addAlpha(border, "99")}`,
@@ -637,12 +653,6 @@ const ColorUsagePreview = ({option, palette, mode}) => {
<PreviewLabel color={textSecondary}>Grouped graph node</PreviewLabel>
</Box>
);
case "floatingAction":
return (
<Box sx={{...shellSx, p: 0.75, backgroundColor: surfaceMuted, display: "flex", alignItems: "center", justifyContent: "center"}}>
<Box sx={{height: 34, width: 34, borderRadius: "50%", backgroundColor: speedDialAction, border: `1px solid ${addAlpha(border, "99")}`, boxShadow: `0 4px 10px ${addAlpha(text, "33")}`}} />
</Box>
);
case "chart":
return (
<Box sx={{...shellSx, p: 0.75, backgroundColor: paper}}>
@@ -661,6 +671,33 @@ const ColorUsagePreview = ({option, palette, mode}) => {
</Box>
</Box>
);
case "chat":
return (
<Box sx={{...shellSx, p: 0.75, backgroundColor: background}}>
<Box sx={{display: "grid", gap: 0.45}}>
<Box sx={{justifySelf: "start", maxWidth: "82%", border: `1px solid ${addAlpha(border, "99")}`, borderRadius: "6px", backgroundColor: chatOperatorBackground, color: text, px: 0.75, py: 0.4}}>
<PreviewLabel color={text}>Other operator</PreviewLabel>
</Box>
<Box sx={{justifySelf: "end", maxWidth: "82%", border: `1px solid ${addAlpha(border, "99")}`, borderRadius: "6px", backgroundColor: chatSelfBackground, color: text, px: 0.75, py: 0.4}}>
<PreviewLabel color={text}>My message</PreviewLabel>
</Box>
<Box sx={{justifySelf: "start", maxWidth: "82%", border: `1px solid ${addAlpha(border, "99")}`, borderRadius: "6px", backgroundColor: chatAIBackground, color: text, px: 0.75, py: 0.4}}>
<PreviewLabel color={text}>AI response</PreviewLabel>
<Box sx={{mt: 0.35, backgroundColor: chatMarkdownSurface, border: `1px solid ${addAlpha(border, "99")}`, borderRadius: "4px", px: 0.5, py: 0.25}}>
<Typography variant="caption" sx={{color: text, display: "block", fontFamily: "monospace", lineHeight: 1.2}}>
markdown block
</Typography>
</Box>
</Box>
<Box sx={{justifySelf: "center", maxWidth: "82%", border: `1px solid ${addAlpha(border, "99")}`, borderRadius: "6px", backgroundColor: chatSystemBackground, color: text, px: 0.75, py: 0.35}}>
<Box sx={{display: "inline-flex", alignItems: "center", gap: 0.4}}>
<PreviewLabel color={text}>System</PreviewLabel>
<Box sx={{height: 12, width: 34, borderRadius: "3px", backgroundColor: chatMarkdownSurfaceStrong, border: `1px solid ${addAlpha(border, "66")}`}} />
</Box>
</Box>
</Box>
</Box>
);
case "task":
return (
<Box sx={{...shellSx, p: 1, backgroundColor: paper}}>
+486 -8
View File
@@ -13,6 +13,7 @@ const getSubtleAccentGradient = (props) => props.theme.gradients?.subtleAccent |
`linear-gradient(135deg, ${alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.13 : 0.075)} 0%, ${alpha(props.theme.palette.background.paper, 0)} 62%)`;
const getSubtleAccentHorizontalGradient = (props) => props.theme.gradients?.subtleAccentHorizontal ||
`linear-gradient(90deg, ${alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.12 : 0.07)} 0%, ${alpha(props.theme.palette.background.paper, 0)} 100%)`;
const getSoftBorderColor = (props) => props.theme.table?.borderSoft || props.theme.borderColor;
export const GlobalStyles = createGlobalStyle`
body {
@@ -131,13 +132,6 @@ tspan {
min-height: unset;
max-width: unset;
}
.MuiSpeedDialAction-staticTooltipLabel {
white-space: nowrap;
max-width: none;
}
.MuiSpeedDialAction-fab {
background-color: ${(props) => props.theme.palette.speedDialAction};
}
.MuiTooltip-tooltip {
background-color: ${(props) => props.theme.palette.background.contrast};
color: ${(props) => props.theme.palette.text.contrast};
@@ -4655,6 +4649,7 @@ tspan {
line-height: 1.4;
margin-top: 0.25rem;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.mythic-metadata-grid {
display: grid;
@@ -9985,7 +9980,7 @@ tspan {
line-height: 1.35;
margin: 0;
overflow: hidden;
white-space: normal;
white-space: pre-wrap;
}
.mythic-installed-service-browser-metadata {
display: flex;
@@ -10111,6 +10106,7 @@ tspan {
font-weight: 650;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.mythic-installed-service-definition-action {
align-items: center;
@@ -10479,6 +10475,488 @@ tspan {
color: inherit;
font-size: 1rem;
}
.mythic-chat-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 8px;
min-height: 0;
flex: 1 1 auto;
border-radius: 8px;
overflow: visible;
}
.mythic-chat-sidebar {
background-color: ${(props) => alpha(props.theme.palette.background.paper, props.theme.palette.mode === "dark" ? 0.72 : 0.86)};
border: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)};
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 8px;
box-shadow: inset 0 1px 0 ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.12)};
overflow: hidden;
}
.mythic-chat-sidebar-toolbar {
background-color: ${(props) => props.theme.pageHeader?.main || props.theme.palette.background.paper};
border-bottom: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)};
color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary};
min-height: 48px;
}
.mythic-chat-sidebar-toolbar,
.mythic-chat-conversation-header,
.mythic-chat-composer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
}
.mythic-chat-sidebar-heading {
align-items: center;
display: flex;
gap: 8px;
min-width: 0;
}
.mythic-chat-sidebar-heading .MuiTypography-root {
font-weight: 750;
}
.mythic-chat-sidebar-count {
background-color: ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.1)} !important;
border: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.24)} !important;
color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary} !important;
font-size: 0.68rem !important;
font-weight: 800 !important;
height: 22px !important;
}
.mythic-chat-conversation-header {
background-color: ${(props) => props.theme.pageHeader?.main || props.theme.palette.background.paper};
border-bottom: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)};
box-shadow: inset 0 1px 0 ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.12)};
color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary};
min-height: 54px;
}
.mythic-chat-composer {
box-shadow: inset 0 1px 0 ${getSoftBorderColor};
align-items: center;
}
.mythic-chat-channel-section {
display: flex;
flex-direction: column;
gap: 5px;
padding: 8px;
overflow-y: auto;
}
.mythic-chat-channel-section > .MuiTypography-caption {
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.mythic-chat-channel-button {
align-items: center;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
display: grid;
gap: 8px;
grid-template-columns: 26px minmax(0, 1fr) auto;
min-height: 42px;
padding: 6px 8px;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease;
}
.mythic-chat-channel-button:hover {
background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover} !important;
border-color: ${getSoftBorderColor} !important;
}
.mythic-chat-channel-button-selected {
box-shadow: inset 3px 0 0 var(--mythic-chat-channel-accent);
}
.mythic-chat-channel-icon,
.mythic-chat-author,
.mythic-chat-search-channel {
align-items: center;
display: inline-flex;
gap: 5px;
}
.mythic-chat-channel-icon {
color: var(--mythic-chat-channel-accent);
justify-content: center;
}
.mythic-chat-channel-button-archived .mythic-chat-channel-icon {
color: var(--mythic-chat-channel-warning);
}
.mythic-chat-channel-button-archived .mythic-chat-channel-name,
.mythic-chat-channel-button-archived .mythic-chat-channel-meta {
color: var(--mythic-chat-channel-muted);
}
.mythic-chat-channel-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.mythic-chat-channel-name {
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-chat-channel-meta {
font-size: 0.74rem;
opacity: 0.72;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-chat-channel-states {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mythic-chat-channel-state,
.mythic-chat-unread-badge {
align-items: center;
background: ${(props) => props.theme.palette.action.selected};
border-radius: 999px;
display: inline-flex;
font-size: 0.66rem;
font-weight: 800;
line-height: 1.2;
padding: 2px 6px;
white-space: nowrap;
}
.mythic-chat-channel-state-archived {
color: var(--mythic-chat-channel-warning);
box-shadow: inset 0 0 0 1px var(--mythic-chat-channel-warning);
}
.mythic-chat-channel-state-locked {
color: var(--mythic-chat-channel-warning);
}
.mythic-chat-channel-state-offline {
color: var(--mythic-chat-channel-error);
}
.mythic-chat-unread-badge {
align-self: center;
color: var(--mythic-chat-channel-error);
box-shadow: inset 0 0 0 1px var(--mythic-chat-channel-error);
}
.mythic-chat-main {
background-color: ${(props) => props.theme.palette.background.paper};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border-radius: 8px;
overflow: hidden;
}
.mythic-chat-conversation-icon {
align-items: center;
border: 1px solid;
border-radius: 7px;
display: inline-flex;
flex: 0 0 auto;
height: 32px;
justify-content: center;
width: 32px;
}
.mythic-chat-conversation-title {
font-weight: 800 !important;
line-height: 1.2 !important;
}
.mythic-chat-conversation-subtitle {
opacity: 0.82;
}
.mythic-chat-header-actions {
align-items: center;
display: flex;
gap: 5px;
}
.mythic-chat-messages {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
.mythic-chat-message-row {
display: flex;
justify-content: flex-start;
}
.mythic-chat-message-row-mine {
justify-content: flex-end;
}
.mythic-chat-message {
border: 1px solid var(--mythic-chat-markdown-border);
border-radius: 8px;
max-width: 100%;
min-width: 0;
overflow: hidden;
padding: 0;
width: fit-content;
}
.mythic-chat-message-header {
align-items: center;
background: var(--mythic-chat-markdown-surface);
border-bottom: 1px solid var(--mythic-chat-markdown-border);
display: flex;
gap: 8px;
justify-content: space-between;
margin: 0;
padding: 7px 10px;
}
.mythic-chat-message-system .mythic-chat-message-header {
background: ${(props) => `linear-gradient(135deg, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.34 : 0.24)} 0%, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.18 : 0.12)} 58%, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.08 : 0.06)} 100%)`};
border-bottom-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.36 : 0.28)};
}
.mythic-chat-author {
font-weight: 750;
min-width: 0;
}
.mythic-chat-author > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-chat-message-actions {
align-items: center;
display: flex;
gap: 2px;
white-space: nowrap;
}
.mythic-chat-message-actions .MuiTypography-caption {
font-size: 0.72rem;
font-weight: 650;
}
.mythic-chat-message-actions .MuiIconButton-root,
.mythic-chat-header-actions .MuiIconButton-root {
border-radius: 6px;
height: 28px;
width: 28px;
}
.mythic-chat-paragraph {
margin: 0 0 6px 0;
overflow-wrap: anywhere;
white-space: normal;
}
.mythic-chat-paragraph:last-child {
margin-bottom: 0;
}
.mythic-chat-markdown {
overflow-wrap: anywhere;
padding: 8px 10px 9px;
}
.mythic-chat-edit-box {
padding: 8px 10px 9px;
}
.mythic-chat-message > .MuiTypography-caption {
padding: 0 10px 8px;
}
.mythic-chat-heading {
font-weight: 800;
line-height: 1.25;
margin: 0 0 6px 0;
}
.mythic-chat-heading-1,
.mythic-chat-heading-2 {
font-size: 1rem;
}
.mythic-chat-heading-3,
.mythic-chat-heading-4,
.mythic-chat-heading-5,
.mythic-chat-heading-6 {
font-size: 0.9rem;
}
.mythic-chat-list {
margin: 4px 0 8px 0;
padding-left: 1.25rem;
}
.mythic-chat-list li {
margin: 2px 0;
padding-left: 0.15rem;
}
.mythic-chat-blockquote {
background: var(--mythic-chat-markdown-surface);
border: 1px solid var(--mythic-chat-markdown-border);
border-left: 3px solid var(--mythic-chat-markdown-border);
border-radius: 7px;
color: inherit;
margin: 6px 0;
padding: 4px 8px 4px 10px;
}
.mythic-chat-rule {
border: 0;
border-top: 1px solid var(--mythic-chat-markdown-border);
margin: 8px 0;
}
.mythic-chat-inline-code {
background: var(--mythic-chat-markdown-surface);
border: 1px solid var(--mythic-chat-markdown-border);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.88em;
padding: 1px 4px;
}
.mythic-chat-code-block {
margin: 6px 0;
position: relative;
}
.mythic-chat-code-block pre {
background: var(--mythic-chat-markdown-surface);
border: 1px solid var(--mythic-chat-markdown-border);
border-radius: 7px;
margin: 0;
overflow-x: auto;
padding: 10px;
}
.mythic-chat-code-block code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.86rem;
}
.mythic-chat-code-language {
background: var(--mythic-chat-markdown-surface-strong);
border: 1px solid var(--mythic-chat-markdown-border);
border-radius: 4px;
font-size: 0.68rem;
font-weight: 700;
padding: 1px 5px;
position: absolute;
right: 6px;
top: 5px;
}
.mythic-chat-table-wrap {
margin: 6px 0;
max-width: 100%;
overflow-x: auto;
width: fit-content;
}
.mythic-chat-table {
font-size: 0.82rem;
min-width: 100%;
width: 100%;
}
.mythic-chat-table .MuiTableCell-root {
font-size: 0.82rem;
vertical-align: top;
}
.mythic-chat-edit-box {
display: flex;
flex-direction: column;
gap: 6px;
}
.mythic-chat-edit-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.mythic-chat-dialog-content {
padding-top: 20px !important;
}
.mythic-chat-composer .MuiFormControl-root {
flex: 1 1 auto;
}
.mythic-chat-composer .MuiOutlinedInput-root {
align-items: center;
background: transparent;
padding-left: 0;
}
.mythic-chat-composer .MuiOutlinedInput-notchedOutline {
border: 0 !important;
}
.mythic-chat-composer .MuiInputBase-input {
padding-left: 0 !important;
}
.mythic-chat-send-button .MuiSvgIcon-root {
display: block;
}
.mythic-chat-system-button.MuiIconButton-root {
border-radius: ${(props) => props.theme.shape.borderRadius}px;
flex: 0 0 auto;
height: 38px;
width: 38px;
}
.mythic-chat-system-destination {
align-items: center;
background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover};
border: 1px solid ${getSoftBorderColor};
border-radius: 7px;
display: flex;
gap: 8px;
min-width: 0;
padding: 8px 10px;
}
.mythic-chat-system-destination .MuiSvgIcon-root {
color: ${(props) => props.theme.palette.warning.main};
}
.mythic-chat-system-options {
background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover};
border: 1px solid ${getSoftBorderColor};
border-radius: 7px;
padding: 4px 10px;
}
.mythic-chat-dialog-content .MuiFormControl-root {
margin-top: 4px;
}
.mythic-chat-empty-state {
align-items: center;
align-self: center;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 6px;
justify-content: center;
min-height: 12rem;
opacity: 0.72;
text-align: center;
}
.mythic-chat-empty-list {
background: ${(props) => props.theme.palette.action.hover};
border-radius: 7px;
font-size: 0.76rem;
opacity: 0.72;
padding: 9px;
}
.mythic-chat-search-results {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 55vh;
overflow-y: auto;
}
.mythic-chat-search-result {
background: transparent;
border: 1px solid ${getSoftBorderColor};
border-radius: 7px;
color: inherit;
cursor: pointer;
display: grid;
gap: 3px;
padding: 8px;
text-align: left;
}
.mythic-chat-search-message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-chat-search-meta {
font-size: 0.74rem;
opacity: 0.72;
}
@media (max-width: 900px) {
.mythic-chat-layout {
grid-template-columns: 1fr;
}
.mythic-chat-sidebar {
max-height: 220px;
}
.mythic-chat-message {
max-width: 100%;
min-width: 0;
}
}
.ace_editor{
background-color: ${(props) => props.theme.outputBackgroundColor + "20"};
}
+99
View File
@@ -51,6 +51,80 @@ type Query {
): ConfigCheckOutput
}
type Mutation {
chatCancelRequest(
request_id: Int!
): ChatActionOutput
}
type Mutation {
chatCreateChannel(
name: String!
description: String
channel_type: String
chat_container_id: Int
chat_model: String
locked: Boolean
ai_metadata: jsonb
): ChatActionOutput
}
type Mutation {
chatCreateMessage(
channel_id: Int!
message: String!
system_message: Boolean
all_operations: Boolean
): ChatActionOutput
}
type Mutation {
chatDeleteMessage(
message_id: Int!
): ChatActionOutput
}
type Mutation {
chatEditMessage(
message_id: Int!
message: String!
): ChatActionOutput
}
type Mutation {
chatMarkRead(
channel_id: Int!
last_read_message_id: Int
): ChatActionOutput
}
type Mutation {
chatRetryRequest(
request_id: Int!
): ChatActionOutput
}
type Query {
chatSearch(
query: String!
channel_id: Int
limit: Int
offset: Int
): ChatActionOutput
}
type Mutation {
chatUpdateChannel(
channel_id: Int!
name: String
description: String
archived: Boolean
locked: Boolean
chat_model: String
ai_metadata: jsonb
): ChatActionOutput
}
type Mutation {
consumingServicesTestLog(
service_type: String!
@@ -1327,3 +1401,28 @@ type custombrowserExportFunctionOutput {
error: String
}
type ChatActionOutput {
status: String!
error: String
id: Int
channel_id: Int
message_id: Int
request_id: Int
response_message_id: Int
results: [ChatSearchResult]
}
type ChatSearchResult {
id: Int!
channel_id: Int!
channel_name: String!
channel_slug: String!
channel_type: String!
author_type: String!
sender_display_name: String!
message: String!
edited: Boolean!
status: String!
created_at: String!
rank: Float!
}
+101
View File
@@ -91,6 +91,107 @@ actions:
- role: mythic_admin
- role: developer
comment: Using the UUID of a payload, ask's that payload's C2 Profiles to check if the Payload's configuration matches with the configuration for the profile itself.
- name: chatCancelRequest
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_cancel_request_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Cancel an in-flight AI chat request
- name: chatCreateChannel
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_create_channel_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Create a standard or AI chat channel in the current operation
- name: chatCreateMessage
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_create_message_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Create a chat message, submit an AI chat prompt, or submit an admin-only system message across one or all operations
- name: chatDeleteMessage
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_delete_message_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Delete a chat message visible in the current operation
- name: chatEditMessage
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_edit_message_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Edit an operator-authored chat message
- name: chatMarkRead
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_mark_read_webhook'
forward_client_headers: true
permissions:
- role: spectator
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Mark a chat channel as read for the current operator
- name: chatRetryRequest
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_retry_request_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Retry an AI chat request in the same AI channel
- name: chatSearch
definition:
kind: ""
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_search_webhook'
forward_client_headers: true
permissions:
- role: spectator
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Search operation chat messages visible to the current token
- name: chatUpdateChannel
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_update_channel_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Update, archive, or lock a chat channel
- name: consumingServicesTestLog
definition:
kind: synchronous
@@ -0,0 +1,194 @@
table:
name: chat_channel
schema: public
object_relationships:
- name: archived_operator
using:
foreign_key_constraint_on: archived_by
- name: chat_container
using:
foreign_key_constraint_on: chat_container_id
- name: creator
using:
foreign_key_constraint_on: created_by
- name: locked_operator
using:
foreign_key_constraint_on: locked_by
- name: operation
using:
foreign_key_constraint_on: operation_id
array_relationships:
- name: messages
using:
foreign_key_constraint_on:
column: channel_id
table:
name: chat_message
schema: public
- name: read_states
using:
foreign_key_constraint_on:
column: channel_id
table:
name: chat_read_state
schema: public
- name: requests
using:
foreign_key_constraint_on:
column: channel_id
table:
name: chat_request
schema: public
select_permissions:
- role: mythic_admin
permission:
columns:
- ai_metadata
- archived
- archived_at
- archived_by
- channel_type
- chat_container_id
- chat_model
- created_at
- created_by
- description
- id
- locked
- locked_at
- locked_by
- last_message_id
- name
- operation_id
- slug
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- _or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operation_admin
permission:
columns:
- ai_metadata
- archived
- archived_at
- archived_by
- channel_type
- chat_container_id
- chat_model
- created_at
- created_by
- description
- id
- locked
- locked_at
- locked_by
- last_message_id
- name
- operation_id
- slug
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- _or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operator
permission:
columns:
- ai_metadata
- archived
- archived_at
- archived_by
- channel_type
- chat_container_id
- chat_model
- created_at
- created_by
- description
- id
- locked
- locked_at
- locked_by
- last_message_id
- name
- operation_id
- slug
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- _or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: spectator
permission:
columns:
- ai_metadata
- archived
- archived_at
- archived_by
- channel_type
- chat_container_id
- chat_model
- created_at
- created_by
- description
- id
- locked
- locked_at
- locked_by
- last_message_id
- name
- operation_id
- slug
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- _or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
@@ -0,0 +1,197 @@
table:
name: chat_message
schema: public
object_relationships:
- name: apitoken
using:
foreign_key_constraint_on: apitokens_id
- name: channel
using:
foreign_key_constraint_on: channel_id
- name: chat_container
using:
foreign_key_constraint_on: chat_container_id
- name: deleted_operator
using:
foreign_key_constraint_on: deleted_by
- name: operation
using:
foreign_key_constraint_on: operation_id
- name: operator
using:
foreign_key_constraint_on: operator_id
array_relationships:
- name: requests_as_prompt
using:
foreign_key_constraint_on:
column: request_message_id
table:
name: chat_request
schema: public
- name: requests_as_response
using:
foreign_key_constraint_on:
column: response_message_id
table:
name: chat_request
schema: public
- name: read_states
using:
foreign_key_constraint_on:
column: last_read_message_id
table:
name: chat_read_state
schema: public
select_permissions:
- role: mythic_admin
permission:
columns:
- apitokens_id
- author_type
- channel_id
- chat_container_id
- created_at
- deleted
- deleted_at
- deleted_by
- edited
- edited_at
- id
- message
- metadata
- operation_id
- operator_id
- sender_display_name
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operation_admin
permission:
columns:
- apitokens_id
- author_type
- channel_id
- chat_container_id
- created_at
- deleted
- deleted_at
- deleted_by
- edited
- edited_at
- id
- message
- metadata
- operation_id
- operator_id
- sender_display_name
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operator
permission:
columns:
- apitokens_id
- author_type
- channel_id
- chat_container_id
- created_at
- deleted
- deleted_at
- deleted_by
- edited
- edited_at
- id
- message
- metadata
- operation_id
- operator_id
- sender_display_name
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: spectator
permission:
columns:
- apitokens_id
- author_type
- channel_id
- chat_container_id
- created_at
- deleted
- deleted_at
- deleted_by
- edited
- edited_at
- id
- message
- metadata
- operation_id
- operator_id
- sender_display_name
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
@@ -0,0 +1,125 @@
table:
name: chat_read_state
schema: public
object_relationships:
- name: channel
using:
foreign_key_constraint_on: channel_id
- name: last_read_message
using:
foreign_key_constraint_on: last_read_message_id
- name: operation
using:
foreign_key_constraint_on: operation_id
- name: operator
using:
foreign_key_constraint_on: operator_id
select_permissions:
- role: mythic_admin
permission:
columns:
- channel_id
- last_read_message_id
- operation_id
- operator_id
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operator_id:
_eq: X-Hasura-User-Id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operation_admin
permission:
columns:
- channel_id
- last_read_message_id
- operation_id
- operator_id
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operator_id:
_eq: X-Hasura-User-Id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operator
permission:
columns:
- channel_id
- last_read_message_id
- operation_id
- operator_id
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operator_id:
_eq: X-Hasura-User-Id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: spectator
permission:
columns:
- channel_id
- last_read_message_id
- operation_id
- operator_id
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operator_id:
_eq: X-Hasura-User-Id
- channel:
_or:
- _and:
- channel_type:
_eq: standard
- operation_id:
_lte: X-Hasura-Scope-chat-read-operation
- _and:
- channel_type:
_eq: ai
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
@@ -0,0 +1,130 @@
table:
name: chat_request
schema: public
object_relationships:
- name: channel
using:
foreign_key_constraint_on: channel_id
- name: chat_container
using:
foreign_key_constraint_on: chat_container_id
- name: creator
using:
foreign_key_constraint_on: created_by
- name: operation
using:
foreign_key_constraint_on: operation_id
- name: request_message
using:
foreign_key_constraint_on: request_message_id
- name: response_message
using:
foreign_key_constraint_on: response_message_id
- name: retry_of
using:
foreign_key_constraint_on: retry_of_id
select_permissions:
- role: mythic_admin
permission:
columns:
- cancelled_at
- channel_id
- chat_container_id
- completed_at
- context_snapshot
- created_at
- created_by
- error
- id
- model
- operation_id
- request_message_id
- response_message_id
- retry_of_id
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operation_admin
permission:
columns:
- cancelled_at
- channel_id
- chat_container_id
- completed_at
- context_snapshot
- created_at
- created_by
- error
- id
- model
- operation_id
- request_message_id
- response_message_id
- retry_of_id
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: operator
permission:
columns:
- cancelled_at
- channel_id
- chat_container_id
- completed_at
- context_snapshot
- created_at
- created_by
- error
- id
- model
- operation_id
- request_message_id
- response_message_id
- retry_of_id
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
- role: spectator
permission:
columns:
- cancelled_at
- channel_id
- chat_container_id
- completed_at
- context_snapshot
- created_at
- created_by
- error
- id
- model
- operation_id
- request_message_id
- response_message_id
- retry_of_id
- status
- updated_at
filter:
_and:
- operation_id:
_eq: X-Hasura-current-operation-id
- operation_id:
_lte: X-Hasura-Scope-chat-ai-read-operation
allow_aggregations: true
@@ -14,6 +14,10 @@
- "!include public_callbackgraphedge.yaml"
- "!include public_callbackport.yaml"
- "!include public_callbacktoken.yaml"
- "!include public_chat_channel.yaml"
- "!include public_chat_message.yaml"
- "!include public_chat_read_state.yaml"
- "!include public_chat_request.yaml"
- "!include public_command.yaml"
- "!include public_commandparameters.yaml"
- "!include public_consuming_container.yaml"
@@ -18,6 +18,12 @@ const (
SCOPE_CALLBACK_READ = "callback.read"
SCOPE_CALLBACK_WRITE = "callback.write"
SCOPE_CHAT_READ = "chat.read"
SCOPE_CHAT_WRITE = "chat.write"
SCOPE_CHAT_AI_READ = "chat-ai.read"
SCOPE_CHAT_AI_WRITE = "chat-ai.write"
SCOPE_CONTAINER_FILE_READ = "container_file.read"
SCOPE_CONTAINER_FILE_WRITE = "container_file.write"
@@ -85,6 +91,10 @@ var scopeDefinitions = []ScopeDefinition{
{Name: SCOPE_C2_WRITE, DisplayName: "Write C2 profiles", Description: "Start, stop, update, or interact with C2 profile containers. Includes read access.", Resource: "c2", Access: "write", Includes: []string{SCOPE_C2_READ}},
{Name: SCOPE_CALLBACK_READ, DisplayName: "Read callbacks", Description: "View callbacks and callback metadata for accessible operations.", Resource: "callback", Access: "read"},
{Name: SCOPE_CALLBACK_WRITE, DisplayName: "Write callbacks", Description: "Create or update callback state. Includes read access.", Resource: "callback", Access: "write", Includes: []string{SCOPE_CALLBACK_READ}},
{Name: SCOPE_CHAT_READ, DisplayName: "Read operation chat", Description: "View and search standard chat channels and messages for accessible operations.", Resource: "chat", Access: "read"},
{Name: SCOPE_CHAT_WRITE, DisplayName: "Write operation chat", Description: "Create standard chat channels and post, edit, or delete standard chat messages. Includes read access.", Resource: "chat", Access: "write", Includes: []string{SCOPE_CHAT_READ}},
{Name: SCOPE_CHAT_AI_READ, DisplayName: "Read AI chat", Description: "View and search AI-backed chat sessions and messages for accessible operations.", Resource: "chat-ai", Access: "read"},
{Name: SCOPE_CHAT_AI_WRITE, DisplayName: "Write AI chat", Description: "Create AI chat sessions, post prompts, and receive AI chat responses. Includes read access.", Resource: "chat-ai", Access: "write", Includes: []string{SCOPE_CHAT_AI_READ}},
{Name: SCOPE_CONTAINER_FILE_READ, DisplayName: "Read container files", Description: "List and download files exposed by Mythic service containers.", Resource: "container_file", Access: "read"},
{Name: SCOPE_CONTAINER_FILE_WRITE, DisplayName: "Write container files", Description: "Write or remove files exposed by Mythic service containers. Includes read access.", Resource: "container_file", Access: "write", Includes: []string{SCOPE_CONTAINER_FILE_READ}},
{Name: SCOPE_CREDENTIAL_READ, DisplayName: "Read credentials", Description: "View credential records for accessible operations.", Resource: "credential", Access: "read"},
@@ -103,6 +103,18 @@ func TestAllowsScope(t *testing.T) {
required: SCOPE_FILE_WRITE,
want: true,
},
{
name: "hyphenated resource wildcard grants ai chat scope",
granted: []string{"chat-ai.*"},
required: SCOPE_CHAT_AI_WRITE,
want: true,
},
{
name: "chat write does not grant ai chat write",
granted: []string{SCOPE_CHAT_WRITE},
required: SCOPE_CHAT_AI_WRITE,
want: false,
},
{
name: "file write grants upload",
granted: []string{SCOPE_FILE_WRITE},
@@ -232,6 +244,16 @@ func TestHasuraScopeRequirementsUseScalarClaims(t *testing.T) {
wantAnchor: "operation",
wantClaim: "x-hasura-scope-operation-write-operation",
},
{
scope: SCOPE_CHAT_READ,
wantAnchor: "operation",
wantClaim: "x-hasura-scope-chat-read-operation",
},
{
scope: SCOPE_CHAT_AI_WRITE,
wantAnchor: "operation",
wantClaim: "x-hasura-scope-chat-ai-write-operation",
},
}
for _, tt := range tests {
@@ -92,6 +92,225 @@ create index if not exists operationeventlog_unresolved_warning_source_operation
on "public"."operationeventlog" using btree (source, operation_id, "level")
where warning=true and resolved=false and deleted=false;
create table if not exists "public"."chat_channel" (
id integer generated by default as identity primary key,
operation_id integer not null references "public"."operation"(id) on delete cascade,
name text not null,
slug text not null,
description text not null default '',
channel_type text not null default 'standard',
created_by integer not null references "public"."operator"(id) on delete restrict,
archived boolean not null default false,
archived_by integer references "public"."operator"(id) on delete set null,
archived_at timestamp without time zone,
locked boolean not null default false,
locked_by integer references "public"."operator"(id) on delete set null,
locked_at timestamp without time zone,
chat_container_id integer references "public"."consuming_container"(id) on delete set null,
chat_model text not null default '',
ai_metadata jsonb not null default jsonb_build_object(),
created_at timestamp without time zone not null default now(),
updated_at timestamp without time zone not null default now(),
constraint chat_channel_type_check check (channel_type in ('standard', 'ai')),
constraint chat_channel_ai_container_check check (channel_type <> 'ai' or chat_container_id is not null)
);
create unique index if not exists chat_channel_operation_slug_unique
on "public"."chat_channel" using btree (operation_id, lower(slug));
create index if not exists chat_channel_operation_type_archived_idx
on "public"."chat_channel" using btree (operation_id, channel_type, archived, name);
create or replace trigger set_public_chat_channel_updated_at
before update on "public"."chat_channel"
for each row execute function public.set_current_timestamp_updated_at();
create table if not exists "public"."chat_message" (
id integer generated by default as identity primary key,
operation_id integer not null references "public"."operation"(id) on delete cascade,
channel_id integer not null references "public"."chat_channel"(id) on delete cascade,
operator_id integer references "public"."operator"(id) on delete set null,
apitokens_id integer references "public"."apitokens"(id) on delete set null,
author_type text not null default 'operator',
chat_container_id integer references "public"."consuming_container"(id) on delete set null,
sender_display_name text not null default '',
message text not null default '',
deleted boolean not null default false,
search_vector tsvector generated always as (
to_tsvector('simple', case when deleted then '' else coalesce(message, '') end)
) stored,
edited boolean not null default false,
edited_at timestamp without time zone,
deleted_by integer references "public"."operator"(id) on delete set null,
deleted_at timestamp without time zone,
status text not null default 'complete',
metadata jsonb not null default jsonb_build_object(),
created_at timestamp without time zone not null default now(),
updated_at timestamp without time zone not null default now(),
constraint chat_message_author_type_check check (author_type in ('operator', 'ai', 'system')),
constraint chat_message_status_check check (status in ('pending', 'streaming', 'complete', 'error', 'cancelled'))
);
create index if not exists chat_message_operation_channel_id_idx
on "public"."chat_message" using btree (operation_id, channel_id, id desc);
create index if not exists chat_message_operation_updated_idx
on "public"."chat_message" using btree (operation_id, updated_at desc);
create index if not exists chat_message_operator_idx
on "public"."chat_message" using btree (operator_id);
create index if not exists chat_message_search_vector_idx
on "public"."chat_message" using gin (search_vector);
create or replace trigger set_public_chat_message_updated_at
before update on "public"."chat_message"
for each row execute function public.set_current_timestamp_updated_at();
alter table "public"."chat_channel"
add column if not exists last_message_id integer;
update "public"."chat_channel" channel
set last_message_id = latest.id
from (
select distinct on (channel_id)
channel_id,
id
from "public"."chat_message"
order by channel_id, id desc
) latest
where channel.id = latest.channel_id
and channel.last_message_id is distinct from latest.id;
create index if not exists chat_channel_operation_unread_idx
on "public"."chat_channel" using btree (operation_id, archived, last_message_id);
-- +migrate StatementBegin
create or replace function public.update_chat_channel_last_message_id() returns trigger
language plpgsql
as $$
begin
update "public"."chat_channel"
set last_message_id = new.id,
updated_at = now()
where id = new.channel_id
and (last_message_id is null or last_message_id < new.id);
return new;
end;
$$;
-- +migrate StatementEnd
create or replace trigger update_chat_channel_last_message_id_trigger
after insert on "public"."chat_message"
for each row execute function public.update_chat_channel_last_message_id();
create table if not exists "public"."chat_request" (
id integer generated by default as identity primary key,
operation_id integer not null references "public"."operation"(id) on delete cascade,
channel_id integer not null references "public"."chat_channel"(id) on delete cascade,
request_message_id integer not null references "public"."chat_message"(id) on delete cascade,
response_message_id integer not null references "public"."chat_message"(id) on delete cascade,
chat_container_id integer not null references "public"."consuming_container"(id) on delete restrict,
model text not null default '',
status text not null default 'pending',
error text not null default '',
context_snapshot jsonb not null default jsonb_build_object(),
retry_of_id integer references "public"."chat_request"(id) on delete set null,
created_by integer not null references "public"."operator"(id) on delete restrict,
created_at timestamp without time zone not null default now(),
updated_at timestamp without time zone not null default now(),
completed_at timestamp without time zone,
cancelled_at timestamp without time zone,
constraint chat_request_status_check check (status in ('pending', 'streaming', 'complete', 'error', 'cancelled'))
);
create index if not exists chat_request_operation_channel_idx
on "public"."chat_request" using btree (operation_id, channel_id, id desc);
create index if not exists chat_request_response_message_id_idx
on "public"."chat_request" using btree (response_message_id);
create or replace trigger set_public_chat_request_updated_at
before update on "public"."chat_request"
for each row execute function public.set_current_timestamp_updated_at();
create table if not exists "public"."chat_read_state" (
operation_id integer not null references "public"."operation"(id) on delete cascade,
channel_id integer not null references "public"."chat_channel"(id) on delete cascade,
operator_id integer not null references "public"."operator"(id) on delete cascade,
last_read_message_id integer references "public"."chat_message"(id) on delete set null,
updated_at timestamp without time zone not null default now(),
primary key (operator_id, channel_id)
);
create index if not exists chat_read_state_operation_operator_idx
on "public"."chat_read_state" using btree (operation_id, operator_id);
create or replace trigger set_public_chat_read_state_updated_at
before update on "public"."chat_read_state"
for each row execute function public.set_current_timestamp_updated_at();
-- +migrate StatementBegin
create or replace function public.create_default_chat_channel_for_operation() returns trigger
language plpgsql
as $$
begin
insert into "public"."chat_channel" (
operation_id,
name,
slug,
description,
channel_type,
created_by
)
values (
new.id,
'general',
'general',
'Default operation chat channel',
'standard',
new.admin_id
)
on conflict do nothing;
return new;
end;
$$;
-- +migrate StatementEnd
create or replace trigger create_default_chat_channel_for_operation_trigger
after insert on "public"."operation"
for each row execute function public.create_default_chat_channel_for_operation();
insert into "public"."chat_channel" (
operation_id,
name,
slug,
description,
channel_type,
created_by
)
select
operation.id,
'general',
'general',
'Default operation chat channel',
'standard',
operation.admin_id
from "public"."operation"
on conflict do nothing;
update "public"."chat_channel"
set
name = 'general',
archived = false,
archived_by = null,
archived_at = null
where channel_type = 'standard'
and lower(slug) = 'general'
and (name <> 'general' or archived = true or archived_by is not null or archived_at is not null);
alter table "public"."task"
add column if not exists subtask_callback_function_started boolean not null default false,
add column if not exists group_callback_function_started boolean not null default false,
@@ -227,6 +446,15 @@ drop index if exists "public"."mythictree_operation_tree_timestamp_idx";
drop index if exists "public"."mythictree_operation_tree_host_full_callback_idx";
drop index if exists "public"."mythictree_operation_tree_host_parent_callback_idx";
drop index if exists "public"."operationeventlog_unresolved_warning_source_operation_level_idx";
drop index if exists "public"."chat_channel_operation_unread_idx";
drop trigger if exists update_chat_channel_last_message_id_trigger on "public"."chat_message";
drop function if exists public.update_chat_channel_last_message_id();
drop trigger if exists create_default_chat_channel_for_operation_trigger on "public"."operation";
drop function if exists public.create_default_chat_channel_for_operation();
drop table if exists "public"."chat_read_state";
drop table if exists "public"."chat_request";
drop table if exists "public"."chat_message";
drop table if exists "public"."chat_channel";
alter table "public"."task"
drop column if exists completed_callback_function_started,
drop column if exists group_callback_function_started,
+111
View File
@@ -0,0 +1,111 @@
package databaseStructs
import (
"database/sql"
"time"
"github.com/its-a-feature/Mythic/utils/structs"
)
const (
ChatChannelTypeStandard = "standard"
ChatChannelTypeAI = "ai"
ChatMessageAuthorOperator = "operator"
ChatMessageAuthorAI = "ai"
ChatMessageAuthorSystem = "system"
ChatMessageStatusPending = "pending"
ChatMessageStatusStreaming = "streaming"
ChatMessageStatusComplete = "complete"
ChatMessageStatusError = "error"
ChatMessageStatusCancelled = "cancelled"
)
type ChatChannel struct {
ID int `db:"id" json:"id"`
OperationID int `db:"operation_id" json:"operation_id"`
Operation Operation `db:"operation" json:"operation,omitempty"`
Name string `db:"name" json:"name"`
Slug string `db:"slug" json:"slug"`
Description string `db:"description" json:"description"`
ChannelType string `db:"channel_type" json:"channel_type"`
CreatedBy int `db:"created_by" json:"created_by"`
Creator Operator `db:"creator" json:"creator,omitempty"`
Archived bool `db:"archived" json:"archived"`
ArchivedBy structs.NullInt64 `db:"archived_by" json:"archived_by"`
ArchivedAt sql.NullTime `db:"archived_at" json:"archived_at"`
Locked bool `db:"locked" json:"locked"`
LockedBy structs.NullInt64 `db:"locked_by" json:"locked_by"`
LockedAt sql.NullTime `db:"locked_at" json:"locked_at"`
LastMessageID structs.NullInt64 `db:"last_message_id" json:"last_message_id"`
ChatContainerID structs.NullInt64 `db:"chat_container_id" json:"chat_container_id"`
ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
ChatModel string `db:"chat_model" json:"chat_model"`
AIMetadata MythicJSONText `db:"ai_metadata" json:"ai_metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type ChatMessage struct {
ID int `db:"id" json:"id"`
OperationID int `db:"operation_id" json:"operation_id"`
Operation Operation `db:"operation" json:"operation,omitempty"`
ChannelID int `db:"channel_id" json:"channel_id"`
Channel ChatChannel `db:"channel" json:"channel,omitempty"`
OperatorID structs.NullInt64 `db:"operator_id" json:"operator_id"`
Operator Operator `db:"operator" json:"operator,omitempty"`
APITokensID structs.NullInt64 `db:"apitokens_id" json:"api_tokens_id" mapstructure:"apitokens_id"`
APIToken Apitokens `db:"apitoken" json:"apitoken,omitempty"`
AuthorType string `db:"author_type" json:"author_type"`
ChatContainerID structs.NullInt64 `db:"chat_container_id" json:"chat_container_id"`
ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"`
Message string `db:"message" json:"message"`
Edited bool `db:"edited" json:"edited"`
EditedAt sql.NullTime `db:"edited_at" json:"edited_at"`
Deleted bool `db:"deleted" json:"deleted"`
DeletedBy structs.NullInt64 `db:"deleted_by" json:"deleted_by"`
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
Status string `db:"status" json:"status"`
Metadata MythicJSONText `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type ChatRequest struct {
ID int `db:"id" json:"id"`
OperationID int `db:"operation_id" json:"operation_id"`
Operation Operation `db:"operation" json:"operation,omitempty"`
ChannelID int `db:"channel_id" json:"channel_id"`
Channel ChatChannel `db:"channel" json:"channel,omitempty"`
RequestMessageID int `db:"request_message_id" json:"request_message_id"`
RequestMessage ChatMessage `db:"request_message" json:"request_message,omitempty"`
ResponseMessageID int `db:"response_message_id" json:"response_message_id"`
ResponseMessage ChatMessage `db:"response_message" json:"response_message,omitempty"`
ChatContainerID int `db:"chat_container_id" json:"chat_container_id"`
ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
Model string `db:"model" json:"model"`
Status string `db:"status" json:"status"`
Error string `db:"error" json:"error"`
ContextSnapshot MythicJSONText `db:"context_snapshot" json:"context_snapshot"`
RetryOfID structs.NullInt64 `db:"retry_of_id" json:"retry_of_id"`
CreatedBy int `db:"created_by" json:"created_by"`
Creator Operator `db:"creator" json:"creator,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"`
CancelledAt sql.NullTime `db:"cancelled_at" json:"cancelled_at"`
}
type ChatReadState struct {
OperationID int `db:"operation_id" json:"operation_id"`
Operation Operation `db:"operation" json:"operation,omitempty"`
ChannelID int `db:"channel_id" json:"channel_id"`
Channel ChatChannel `db:"channel" json:"channel,omitempty"`
OperatorID int `db:"operator_id" json:"operator_id"`
Operator Operator `db:"operator" json:"operator,omitempty"`
LastReadMessageID structs.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastReadMessage ChatMessage `db:"last_read_message" json:"last_read_message,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
@@ -205,6 +205,8 @@ func CreateGraphQLSpectatorAPITokenAndSendOnStartMessage(containerName string) {
mythicjwt.SCOPE_FILE_WRITE,
mythicjwt.SCOPE_TAG_WRITE,
mythicjwt.SCOPE_CALLBACK_WRITE,
mythicjwt.SCOPE_CHAT_AI_READ,
mythicjwt.SCOPE_CHAT_AI_WRITE,
}
statement, err := database.DB.PrepareNamed(`INSERT INTO apitokens
(token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, scopes)
+4
View File
@@ -89,6 +89,9 @@ const (
CONTAINER_ON_START = "container_on_start"
CONTAINER_ON_START_RESPONSE = "container_on_start_response"
// CHAT
CHAT_RESPONSE_ROUTING_KEY = "chat_response"
// CUSTOM_BROWSER routes
CUSTOMBROWSER_SYNC_ROUTING_KEY = "custombrowser_sync"
CUSTOMBROWSER_EXPORT_FUNCTION = "custombrowser_exportfunction"
@@ -124,6 +127,7 @@ const (
//
PT_TASK_PROCESS_RESPONSE = "pt_task_process_response"
PT_COMMAND_HELP_FUNCTION = "pt_command_help_function"
CHAT_REQUEST = "chat_request"
)
// Routes where container is consuming messages and responding back to Mythic
+2
View File
@@ -31,6 +31,7 @@ type DirectQueueStruct struct {
RoutingKey string
Handler QueueHandler
Scopes []string
Sequential bool
}
type channelMutex struct {
@@ -90,6 +91,7 @@ func (r *rabbitMQConnection) startListeners() {
directQueue.Queue,
directQueue.RoutingKey,
directQueue.Handler,
directQueue.Sequential,
exclusiveQueue)
}
go checkContainerStatus()
@@ -0,0 +1,304 @@
package rabbitmq
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/its-a-feature/Mythic/authentication/mythicjwt"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/logging"
amqp "github.com/rabbitmq/amqp091-go"
)
const chatStreamFlushInterval = 500 * time.Millisecond
type ChatContainerResponseMessage struct {
OperationID int `json:"operation_id" mapstructure:"operation_id"`
RequestID int `json:"request_id" mapstructure:"request_id"`
ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"`
Content string `json:"content" mapstructure:"content"`
IsDelta bool `json:"is_delta" mapstructure:"is_delta"`
Complete bool `json:"complete" mapstructure:"complete"`
Status string `json:"status" mapstructure:"status"`
Error string `json:"error" mapstructure:"error"`
Metadata map[string]interface{} `json:"metadata" mapstructure:"metadata"`
}
type chatResponseRequest struct {
ID int `db:"id"`
OperationID int `db:"operation_id"`
ResponseMessageID int `db:"response_message_id"`
Status string `db:"status"`
}
type chatStreamBuffer struct {
OperationID int
Pending string
Timer *time.Timer
}
var (
chatStreamBuffers = map[int]*chatStreamBuffer{}
chatStreamBuffersLock sync.Mutex
chatResponseLocks sync.Map
)
func init() {
RabbitMQConnection.AddDirectQueue(DirectQueueStruct{
Exchange: MYTHIC_EXCHANGE,
Queue: CHAT_RESPONSE_ROUTING_KEY,
RoutingKey: CHAT_RESPONSE_ROUTING_KEY,
Handler: processChatContainerResponse,
Scopes: []string{mythicjwt.SCOPE_CHAT_AI_WRITE},
Sequential: true,
})
}
func processChatContainerResponse(msg amqp.Delivery) {
incomingMessage := ChatContainerResponseMessage{}
if err := json.Unmarshal(msg.Body, &incomingMessage); err != nil {
logging.LogError(err, "Failed to process chat container response message")
return
}
authContext, err := GetRabbitMQAuthContextFromHeaders(msg.Headers)
if err != nil {
logging.LogError(err, "Failed to get chat response auth headers")
return
}
if err = applyChatContainerResponse(incomingMessage, authContext); err != nil {
logging.LogError(err, "Failed to apply chat container response",
"request_id", incomingMessage.RequestID, "response_message_id", incomingMessage.ResponseMessageID)
}
}
func applyChatContainerResponse(incomingMessage ChatContainerResponseMessage, authContext RabbitMQAuthContext) error {
request, err := getChatResponseRequest(incomingMessage, authContext.OperationID)
if err != nil {
return err
}
if request.Status == databaseStructs.ChatMessageStatusCancelled {
return nil
}
if incomingMessage.OperationID > 0 && incomingMessage.OperationID != request.OperationID {
return fmt.Errorf("chat response operation %d does not match request operation %d",
incomingMessage.OperationID, request.OperationID)
}
if authContext.OperationID > 0 && authContext.OperationID != request.OperationID {
return fmt.Errorf("chat response auth operation %d does not match request operation %d",
authContext.OperationID, request.OperationID)
}
if incomingMessage.ResponseMessageID > 0 && incomingMessage.ResponseMessageID != request.ResponseMessageID {
return fmt.Errorf("chat response message %d does not match request response message %d",
incomingMessage.ResponseMessageID, request.ResponseMessageID)
}
chatResponseLock := getChatResponseLock(request.ResponseMessageID)
chatResponseLock.Lock()
defer chatResponseLock.Unlock()
if request, err = getChatResponseRequest(ChatContainerResponseMessage{RequestID: request.ID}, request.OperationID); err != nil {
return err
}
if isTerminalChatResponseStatus(request.Status) {
return nil
}
status := normalizeChatContainerResponseStatus(incomingMessage)
if incomingMessage.Content != "" {
if incomingMessage.IsDelta {
if err = queueChatResponseDelta(request.ResponseMessageID, request.OperationID, incomingMessage.Content); err != nil {
return err
}
} else {
if err = flushChatResponseMessageLocked(request.ResponseMessageID, request.OperationID, false); err != nil {
return err
}
if err = setChatResponseMessageContent(request.ResponseMessageID, request.OperationID, incomingMessage.Content); err != nil {
return err
}
}
if status == "" {
status = databaseStructs.ChatMessageStatusStreaming
}
}
if status == databaseStructs.ChatMessageStatusComplete ||
status == databaseStructs.ChatMessageStatusError ||
status == databaseStructs.ChatMessageStatusCancelled {
if err = flushChatResponseMessageLocked(request.ResponseMessageID, request.OperationID, true); err != nil {
return err
}
}
if status == "" && len(incomingMessage.Metadata) > 0 {
status = request.Status
}
if status != "" {
if err = setChatResponseStatus(request, status, incomingMessage.Error, incomingMessage.Metadata); err != nil {
return err
}
if isTerminalChatResponseStatus(status) {
chatResponseLocks.Delete(request.ResponseMessageID)
}
}
return nil
}
func getChatResponseRequest(incomingMessage ChatContainerResponseMessage, operationID int) (chatResponseRequest, error) {
request := chatResponseRequest{}
if incomingMessage.RequestID <= 0 && incomingMessage.ResponseMessageID <= 0 {
return request, errors.New("chat response requires request_id or response_message_id")
}
whereClause := "id=$1"
arg := incomingMessage.RequestID
if incomingMessage.RequestID <= 0 {
whereClause = "response_message_id=$1"
arg = incomingMessage.ResponseMessageID
}
sqlStatement := fmt.Sprintf(`SELECT id, operation_id, response_message_id, status
FROM chat_request
WHERE %s`, whereClause)
args := []interface{}{arg}
if operationID > 0 {
sqlStatement += " AND operation_id=$2"
args = append(args, operationID)
}
if err := database.DB.Get(&request, sqlStatement, args...); err != nil {
return request, err
}
return request, nil
}
func normalizeChatContainerResponseStatus(incomingMessage ChatContainerResponseMessage) string {
status := strings.ToLower(strings.TrimSpace(incomingMessage.Status))
switch status {
case databaseStructs.ChatMessageStatusStreaming,
databaseStructs.ChatMessageStatusComplete,
databaseStructs.ChatMessageStatusError,
databaseStructs.ChatMessageStatusCancelled:
return status
}
if incomingMessage.Error != "" {
return databaseStructs.ChatMessageStatusError
}
if incomingMessage.Complete {
return databaseStructs.ChatMessageStatusComplete
}
return ""
}
func isTerminalChatResponseStatus(status string) bool {
return status == databaseStructs.ChatMessageStatusComplete ||
status == databaseStructs.ChatMessageStatusError ||
status == databaseStructs.ChatMessageStatusCancelled
}
func getChatResponseLock(responseMessageID int) *sync.Mutex {
lock, _ := chatResponseLocks.LoadOrStore(responseMessageID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func queueChatResponseDelta(responseMessageID int, operationID int, delta string) error {
chatStreamBuffersLock.Lock()
defer chatStreamBuffersLock.Unlock()
buffer, ok := chatStreamBuffers[responseMessageID]
if !ok {
buffer = &chatStreamBuffer{OperationID: operationID}
chatStreamBuffers[responseMessageID] = buffer
}
if buffer.OperationID > 0 && operationID > 0 && buffer.OperationID != operationID {
return fmt.Errorf("chat response buffer operation %d does not match incoming operation %d for response message %d",
buffer.OperationID, operationID, responseMessageID)
}
if buffer.OperationID == 0 {
buffer.OperationID = operationID
}
buffer.Pending += delta
if buffer.Timer == nil {
buffer.Timer = time.AfterFunc(chatStreamFlushInterval, func() {
if err := flushChatResponseMessage(responseMessageID, operationID, false); err != nil {
logging.LogError(err, "Failed to flush chat response buffer", "response_message_id", responseMessageID)
}
})
}
return nil
}
func flushChatResponseMessage(responseMessageID int, operationID int, final bool) error {
chatResponseLock := getChatResponseLock(responseMessageID)
chatResponseLock.Lock()
defer chatResponseLock.Unlock()
return flushChatResponseMessageLocked(responseMessageID, operationID, final)
}
func flushChatResponseMessageLocked(responseMessageID int, operationID int, final bool) error {
chatStreamBuffersLock.Lock()
buffer, ok := chatStreamBuffers[responseMessageID]
if !ok {
chatStreamBuffersLock.Unlock()
return nil
}
if buffer.OperationID > 0 && operationID > 0 && buffer.OperationID != operationID {
chatStreamBuffersLock.Unlock()
return fmt.Errorf("chat response buffer operation %d does not match flush operation %d for response message %d",
buffer.OperationID, operationID, responseMessageID)
}
pending := buffer.Pending
buffer.Pending = ""
if buffer.Timer != nil {
buffer.Timer.Stop()
buffer.Timer = nil
}
if final {
delete(chatStreamBuffers, responseMessageID)
}
chatStreamBuffersLock.Unlock()
if pending == "" {
return nil
}
_, err := database.DB.Exec(`UPDATE chat_message
SET message = message || $2,
status = CASE WHEN status IN ('pending', 'streaming') THEN 'streaming' ELSE status END
WHERE id=$1 AND operation_id=$3 AND deleted=false`, responseMessageID, pending, operationID)
return err
}
func setChatResponseMessageContent(responseMessageID int, operationID int, content string) error {
_, err := database.DB.Exec(`UPDATE chat_message
SET message=$3,
status = CASE WHEN status IN ('pending', 'streaming') THEN 'streaming' ELSE status END
WHERE id=$1 AND operation_id=$2 AND deleted=false`, responseMessageID, operationID, content)
return err
}
func setChatResponseStatus(request chatResponseRequest, status string, responseError string, metadata map[string]interface{}) error {
if len(metadata) == 0 {
metadata = map[string]interface{}{}
}
metadataJSON := GetMythicJSONTextFromStruct(metadata)
if responseError != "" {
metadataJSON = GetMythicJSONTextFromStruct(map[string]interface{}{
"container_metadata": metadata,
"error": responseError,
})
}
_, err := database.DB.Exec(`UPDATE chat_message
SET status=$3,
metadata = metadata || $4::jsonb
WHERE id=$1 AND operation_id=$2 AND deleted=false`, request.ResponseMessageID, request.OperationID, status, metadataJSON.String())
if err != nil {
return err
}
_, err = database.DB.Exec(`UPDATE chat_request
SET status=$2,
error=$3,
completed_at=CASE WHEN $2='complete' THEN now() ELSE completed_at END,
cancelled_at=CASE WHEN $2='cancelled' THEN now() ELSE cancelled_at END
WHERE id=$1 AND operation_id=$4 AND status <> 'cancelled'`, request.ID, status, responseError, request.OperationID)
return err
}
@@ -38,6 +38,7 @@ const (
CONSUMING_SERVICES_TYPE_WEBHOOK = "webhook"
CONSUMING_SERVICES_TYPE_EVENTING = "eventing"
CONSUMING_SERVICES_TYPE_SCRIPTING = "scripting"
CONSUMING_SERVICES_TYPE_CHAT = "chat"
)
func init() {
@@ -0,0 +1,51 @@
package rabbitmq
import (
"time"
"github.com/its-a-feature/Mythic/logging"
)
type ChatContainerContextMessage struct {
ID int `db:"id" json:"id" mapstructure:"id"`
AuthorType string `db:"author_type" json:"author_type" mapstructure:"author_type"`
SenderDisplayName string `db:"sender_display_name" json:"sender_display_name" mapstructure:"sender_display_name"`
Message string `db:"message" json:"message" mapstructure:"message"`
CreatedAt time.Time `db:"created_at" json:"created_at" mapstructure:"created_at"`
}
type ChatContainerRequestMessage struct {
ContainerName string `json:"container_name" mapstructure:"container_name"`
OperationID int `json:"operation_id" mapstructure:"operation_id"`
ChannelID int `json:"channel_id" mapstructure:"channel_id"`
ChannelName string `json:"channel_name" mapstructure:"channel_name"`
ChannelSlug string `json:"channel_slug" mapstructure:"channel_slug"`
RequestID int `json:"request_id" mapstructure:"request_id"`
RequestMessageID int `json:"request_message_id" mapstructure:"request_message_id"`
ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"`
Model string `json:"model" mapstructure:"model"`
Prompt string `json:"prompt" mapstructure:"prompt"`
Context []ChatContainerContextMessage `json:"context" mapstructure:"context"`
Secrets map[string]interface{} `json:"secrets" mapstructure:"secrets"`
}
func (r *rabbitMQConnection) SendChatContainerRequest(containerName string, chatMessage ChatContainerRequestMessage, authContext RabbitMQAuthContext) error {
chatMessage.ContainerName = containerName
headers, err := GenerateRabbitMQAuthTokenHeader(authContext)
if err != nil {
logging.LogError(err, "Failed to generate auth context for chat request")
return err
}
if err = r.SendStructMessage(
MYTHIC_EXCHANGE,
GetChatContainerRequestRoutingKey(containerName),
"",
chatMessage,
false,
headers,
); err != nil {
logging.LogError(err, "Failed to send chat request", "container", containerName, "request_id", chatMessage.RequestID)
return err
}
return nil
}
@@ -59,6 +59,9 @@ func GetPtTaskProcessResponseRoutingKey(container string) string {
func GetPtCommandHelpRoutingKey(container string) string {
return fmt.Sprintf("%s_%s", container, PT_COMMAND_HELP_FUNCTION)
}
func GetChatContainerRequestRoutingKey(container string) string {
return fmt.Sprintf("%s_%s", container, CHAT_REQUEST)
}
// c2 rpc routing key functions
func GetC2RPCOpsecChecksRoutingKey(container string) string {
@@ -519,7 +522,7 @@ func (r *rabbitMQConnection) SendRPCMessage(exchange string, queue string, body
logging.LogError(finalError, "failed 3 times")
return nil, finalError
}
func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, queue string, routingKey string, handler QueueHandler, exclusiveQueue bool) {
func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, queue string, routingKey string, handler QueueHandler, sequential bool, exclusiveQueue bool) {
// exchange is a direct exchange
// queue is where the messages get sent to (local name)
// routingKey is the specific direct topic we're interested in for the exchange
@@ -603,7 +606,11 @@ func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, qu
logging.LogError(err, "RabbitMQ direct exchange auth check failed", "queue", queue)
continue
}
go handler(d)
if sequential {
handler(d)
} else {
go handler(d)
}
}
forever <- true
}()
File diff suppressed because it is too large Load Diff
@@ -46,6 +46,8 @@ func Login(c *gin.Context) {
"username": currentOperation.CurrentOperator.Username,
"id": currentOperation.CurrentOperator.ID,
"user_id": currentOperation.CurrentOperator.ID,
"admin": currentOperation.CurrentOperator.Admin,
"view_mode": currentOperation.ViewMode,
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
}
@@ -75,8 +77,12 @@ func GetMeWebhook(c *gin.Context) {
"current_operation_banner_color": currentOperation.CurrentOperation.BannerColor,
"current_operation_complete": currentOperation.CurrentOperation.Complete,
"current_operation_id": currentOperation.CurrentOperation.ID,
"username": currentOperation.CurrentOperator.Username,
"user_id": currentOperation.CurrentOperator.ID,
"id": currentOperation.CurrentOperator.ID,
"admin": currentOperation.CurrentOperator.Admin,
"view_mode": currentOperation.ViewMode,
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
"scope_info": buildScopeIntrospectionResponse(c, currentClaimsOrNil(c)),
})
@@ -162,6 +168,8 @@ func RefreshJWT(c *gin.Context) {
"username": currentOperation.CurrentOperator.Username,
"id": currentOperation.CurrentOperator.ID,
"user_id": currentOperation.CurrentOperator.ID,
"admin": currentOperation.CurrentOperator.Admin,
"view_mode": currentOperation.ViewMode,
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
}
+19
View File
@@ -313,6 +313,10 @@ func setRoutes(r *gin.Engine) {
mythicjwt.SCOPE_FILE_READ,
}),
webcontroller.DownloadBulkFilesWebhook)
allOperationMembers.POST("chat_search_webhook",
webcontroller.ChatSearchWebhook)
allOperationMembers.POST("chat_mark_read_webhook",
webcontroller.MarkChatReadWebhook)
allOperationMembers.POST("preview_file_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_FILE_READ,
@@ -354,6 +358,21 @@ func setRoutes(r *gin.Engine) {
webcontroller.ConsumingServicesTestWebhook)
noSpectators.POST("consuming_services_test_log",
webcontroller.ConsumingServicesTestLog)
// chat
noSpectators.POST("chat_create_channel_webhook",
webcontroller.CreateChatChannelWebhook)
noSpectators.POST("chat_update_channel_webhook",
webcontroller.UpdateChatChannelWebhook)
noSpectators.POST("chat_create_message_webhook",
webcontroller.CreateChatMessageWebhook)
noSpectators.POST("chat_edit_message_webhook",
webcontroller.EditChatMessageWebhook)
noSpectators.POST("chat_delete_message_webhook",
webcontroller.DeleteChatMessageWebhook)
noSpectators.POST("chat_cancel_request_webhook",
webcontroller.CancelChatRequestWebhook)
noSpectators.POST("chat_retry_request_webhook",
webcontroller.RetryChatRequestWebhook)
// creating a payload
noSpectators.POST("createpayload_webhook",
authentication.TokenScopeMiddleware([]string{