mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
feat(ui): surface the auto-approve outcome on audit rows with a create-grant shortcut
This commit is contained in:
parent
aa54058e31
commit
be7b236cb5
@ -1861,6 +1861,16 @@ export default {
|
|||||||
},
|
},
|
||||||
noLogs: 'No audit logs yet',
|
noLogs: 'No audit logs yet',
|
||||||
expandFindings: 'Expand Details',
|
expandFindings: 'Expand Details',
|
||||||
|
outcome: {
|
||||||
|
AUTO_GRANT: 'Auto-approved',
|
||||||
|
HARD_BLOCK: 'Blocked by safety floor',
|
||||||
|
FORCE_HUMAN: 'Dangerous pattern, human approval forced',
|
||||||
|
SEVERITY_CRITICAL: 'Not auto-approved: CRITICAL always requires a human',
|
||||||
|
SEVERITY_CEILING: 'Not auto-approved: grant ceiling below this finding ({ceiling} < {actual})',
|
||||||
|
UNKNOWN_WORKSPACE: 'Not auto-approved: conversation workspace unknown',
|
||||||
|
NO_GRANT: 'Not auto-approved: no matching grant',
|
||||||
|
createGrant: 'Create a grant',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
severity: {
|
severity: {
|
||||||
CRITICAL: 'Critical',
|
CRITICAL: 'Critical',
|
||||||
|
|||||||
@ -1735,6 +1735,16 @@ export default {
|
|||||||
},
|
},
|
||||||
noLogs: '暂无审计记录',
|
noLogs: '暂无审计记录',
|
||||||
expandFindings: '展开详情',
|
expandFindings: '展开详情',
|
||||||
|
outcome: {
|
||||||
|
AUTO_GRANT: '已自动放行',
|
||||||
|
HARD_BLOCK: '安全地板阻断',
|
||||||
|
FORCE_HUMAN: '高危模式,强制人审',
|
||||||
|
SEVERITY_CRITICAL: '未自动放行:CRITICAL 永远人审',
|
||||||
|
SEVERITY_CEILING: '未自动放行:策略严重度上限低于本次命中({ceiling} < {actual})',
|
||||||
|
UNKNOWN_WORKSPACE: '未自动放行:无法确定会话所属工作区',
|
||||||
|
NO_GRANT: '未自动放行:无匹配的自动批准策略',
|
||||||
|
createGrant: '去创建策略',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
severity: {
|
severity: {
|
||||||
CRITICAL: '严重',
|
CRITICAL: '严重',
|
||||||
|
|||||||
@ -110,6 +110,17 @@
|
|||||||
<span class="decision-badge" :class="'decision-' + log.decision?.toLowerCase()">
|
<span class="decision-badge" :class="'decision-' + log.decision?.toLowerCase()">
|
||||||
{{ t('security.decision.' + log.decision) || log.decision }}
|
{{ t('security.decision.' + log.decision) || log.decision }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Auto-approve resolution outcome: why this NEEDS_APPROVAL call
|
||||||
|
was (not) auto-approved. NULL on rows predating the column
|
||||||
|
or on decisions that never reach the auto-grant layer. -->
|
||||||
|
<div
|
||||||
|
v-if="log.autoApproveOutcome"
|
||||||
|
class="outcome-chip"
|
||||||
|
:class="outcomeClass(log.autoApproveOutcome)"
|
||||||
|
:title="log.autoApproveOutcome"
|
||||||
|
>
|
||||||
|
{{ outcomeText(log.autoApproveOutcome) }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="log.maxSeverity" class="severity-badge" :class="'severity-' + log.maxSeverity?.toLowerCase()">
|
<span v-if="log.maxSeverity" class="severity-badge" :class="'severity-' + log.maxSeverity?.toLowerCase()">
|
||||||
@ -119,7 +130,7 @@
|
|||||||
<td class="cell-conv">{{ truncateConvId(log.conversationId) }}</td>
|
<td class="cell-conv">{{ truncateConvId(log.conversationId) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
v-if="log.findingsJson"
|
v-if="log.findingsJson || log.autoApproveOutcome"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
@click="toggleExpand(log.id)"
|
@click="toggleExpand(log.id)"
|
||||||
:title="t('security.audit.expandFindings')"
|
:title="t('security.audit.expandFindings')"
|
||||||
@ -145,6 +156,14 @@
|
|||||||
<span class="finding-title">{{ finding.title }}</span>
|
<span class="finding-title">{{ finding.title }}</span>
|
||||||
<span v-if="finding.remediation" class="finding-remediation">{{ finding.remediation }}</span>
|
<span v-if="finding.remediation" class="finding-remediation">{{ finding.remediation }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Fixable misses (no grant, or a grant with a too-low ceiling)
|
||||||
|
get a one-click jump to the grant form, prefilled with this
|
||||||
|
row's tool and actual severity. -->
|
||||||
|
<div v-if="canCreateGrantFrom(log)" class="outcome-actions">
|
||||||
|
<button class="btn-secondary btn-sm" @click="goCreateGrant(log)">
|
||||||
|
{{ t('security.audit.outcome.createGrant') }} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -174,11 +193,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { securityApi } from '@/api'
|
import { securityApi } from '@/api'
|
||||||
import { parseFindings, formatTime, truncateConvId } from '../composables/helpers'
|
import { parseFindings, formatTime, truncateConvId } from '../composables/helpers'
|
||||||
import type { AuditStats } from '@/types'
|
import type { AuditStats } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const auditLogs = ref<any[]>([])
|
const auditLogs = ref<any[]>([])
|
||||||
const auditStats = reactive<AuditStats>({ total: 0, blocked: 0, needsApproval: 0, allowed: 0 })
|
const auditStats = reactive<AuditStats>({ total: 0, blocked: 0, needsApproval: 0, allowed: 0 })
|
||||||
@ -244,6 +265,55 @@ async function loadAuditStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits an auto_approve_outcome code into an i18n key plus params.
|
||||||
|
* SEVERITY_CEILING carries detail as "SEVERITY_CEILING:LOW<HIGH";
|
||||||
|
* FORCE_HUMAN carries the floor pattern as "FORCE_HUMAN:pattern".
|
||||||
|
*/
|
||||||
|
function parseOutcome(oc: string): { key: string; params: Record<string, string> } {
|
||||||
|
if (oc.startsWith('SEVERITY_CEILING:')) {
|
||||||
|
const [ceiling, actual] = oc.slice('SEVERITY_CEILING:'.length).split('<')
|
||||||
|
return { key: 'SEVERITY_CEILING', params: { ceiling: ceiling || '?', actual: actual || '?' } }
|
||||||
|
}
|
||||||
|
if (oc.startsWith('FORCE_HUMAN')) {
|
||||||
|
return { key: 'FORCE_HUMAN', params: {} }
|
||||||
|
}
|
||||||
|
return { key: oc, params: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeText(oc: string): string {
|
||||||
|
const { key, params } = parseOutcome(oc)
|
||||||
|
const i18nKey = `security.audit.outcome.${key}`
|
||||||
|
const txt = t(i18nKey, params)
|
||||||
|
// vue-i18n echoes the key back when no message exists — fall back to the raw code.
|
||||||
|
return txt === i18nKey ? oc : txt
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeClass(oc: string): string {
|
||||||
|
const { key } = parseOutcome(oc)
|
||||||
|
if (key === 'AUTO_GRANT') return 'outcome-granted'
|
||||||
|
if (key === 'HARD_BLOCK') return 'outcome-blocked'
|
||||||
|
return 'outcome-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only misses fixable by creating a grant get the shortcut. */
|
||||||
|
function canCreateGrantFrom(log: any): boolean {
|
||||||
|
if (!log.autoApproveOutcome) return false
|
||||||
|
const { key } = parseOutcome(log.autoApproveOutcome)
|
||||||
|
return key === 'SEVERITY_CEILING' || key === 'NO_GRANT'
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCreateGrant(log: any) {
|
||||||
|
router.push({
|
||||||
|
name: 'SecurityAutoApprove',
|
||||||
|
query: {
|
||||||
|
create: '1',
|
||||||
|
tool: log.toolName || '',
|
||||||
|
severity: log.maxSeverity || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function toggleExpand(id: number) {
|
function toggleExpand(id: number) {
|
||||||
if (expandedRows.value.has(id)) {
|
if (expandedRows.value.has(id)) {
|
||||||
expandedRows.value.delete(id)
|
expandedRows.value.delete(id)
|
||||||
@ -438,6 +508,25 @@ onMounted(async () => {
|
|||||||
.finding-title { color: var(--mc-text-primary); }
|
.finding-title { color: var(--mc-text-primary); }
|
||||||
.finding-remediation { color: var(--mc-text-tertiary); font-style: italic; }
|
.finding-remediation { color: var(--mc-text-tertiary); font-style: italic; }
|
||||||
|
|
||||||
|
/* Auto-approve outcome chip — sits under the decision badge. Three tones:
|
||||||
|
granted (auto-approved), denied (fell back to human), blocked (safety floor). */
|
||||||
|
.outcome-chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.outcome-granted { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||||
|
.outcome-denied { background: rgba(245, 158, 11, 0.12); color: #b45309; }
|
||||||
|
.outcome-blocked { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||||
|
|
||||||
|
.outcome-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -341,6 +341,7 @@
|
|||||||
import { ElMessage } from 'element-plus/es/components/message/index'
|
import { ElMessage } from 'element-plus/es/components/message/index'
|
||||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Delete,
|
Delete,
|
||||||
Lock,
|
Lock,
|
||||||
@ -364,6 +365,8 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
@ -603,7 +606,34 @@ function formatDate(s: string | null): string {
|
|||||||
return d.toLocaleString()
|
return d.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadGrants)
|
onMounted(() => {
|
||||||
|
loadGrants()
|
||||||
|
applyCreateQuery()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry from the audit-log page's "create grant" shortcut:
|
||||||
|
* /security/auto-approve?create=1&tool=<name>&severity=<LOW|MEDIUM|HIGH>
|
||||||
|
* opens the create dialog prefilled with the missed invocation's tool and
|
||||||
|
* actual severity (WORKSPACE scope, current workspace). The query is
|
||||||
|
* consumed once and stripped so refresh/back doesn't re-open the dialog.
|
||||||
|
*/
|
||||||
|
function applyCreateQuery() {
|
||||||
|
if (route.query.create !== '1') return
|
||||||
|
const tool = typeof route.query.tool === 'string' ? route.query.tool : ''
|
||||||
|
const severity = typeof route.query.severity === 'string' ? route.query.severity : ''
|
||||||
|
openCreateDialog(false)
|
||||||
|
form.scopeType = 'WORKSPACE'
|
||||||
|
prefillScopeId()
|
||||||
|
form.toolName = tool
|
||||||
|
if (severity === 'LOW' || severity === 'MEDIUM' || severity === 'HIGH') {
|
||||||
|
form.maxSeverity = severity
|
||||||
|
} else if (severity === 'CRITICAL') {
|
||||||
|
// CRITICAL is never auto-approvable; HIGH is the closest configurable ceiling.
|
||||||
|
form.maxSeverity = 'HIGH'
|
||||||
|
}
|
||||||
|
router.replace({ query: {} })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user