securityclaw 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/README.zh-CN.md +135 -0
  5. package/admin/public/app.js +148 -0
  6. package/admin/public/favicon.svg +21 -0
  7. package/admin/public/index.html +31 -0
  8. package/admin/public/styles.css +2715 -0
  9. package/admin/server.ts +1053 -0
  10. package/bin/install-lib.mjs +88 -0
  11. package/bin/securityclaw.mjs +66 -0
  12. package/config/policy.default.yaml +520 -0
  13. package/index.ts +2662 -0
  14. package/install.sh +22 -0
  15. package/openclaw.plugin.json +60 -0
  16. package/package.json +69 -0
  17. package/src/admin/build.ts +113 -0
  18. package/src/admin/console_notice.ts +195 -0
  19. package/src/admin/dashboard_url_state.ts +80 -0
  20. package/src/admin/openclaw_session_catalog.ts +137 -0
  21. package/src/admin/runtime_guard.ts +51 -0
  22. package/src/admin/skill_interception_store.ts +1606 -0
  23. package/src/application/commands/approval_commands.ts +189 -0
  24. package/src/approvals/chat_approval_store.ts +433 -0
  25. package/src/config/live_config.ts +144 -0
  26. package/src/config/loader.ts +168 -0
  27. package/src/config/runtime_override.ts +66 -0
  28. package/src/config/strategy_store.ts +121 -0
  29. package/src/config/validator.ts +222 -0
  30. package/src/domain/models/resource_context.ts +31 -0
  31. package/src/domain/ports/approval_repository.ts +40 -0
  32. package/src/domain/ports/notification_port.ts +29 -0
  33. package/src/domain/ports/openclaw_adapter.ts +22 -0
  34. package/src/domain/services/account_policy_engine.ts +163 -0
  35. package/src/domain/services/approval_service.ts +336 -0
  36. package/src/domain/services/approval_subject_resolver.ts +37 -0
  37. package/src/domain/services/context_inference_service.ts +502 -0
  38. package/src/domain/services/file_rule_registry.ts +171 -0
  39. package/src/domain/services/formatting_service.ts +101 -0
  40. package/src/domain/services/path_candidate_inference.ts +111 -0
  41. package/src/domain/services/sensitive_path_registry.ts +288 -0
  42. package/src/domain/services/sensitivity_label_inference.ts +161 -0
  43. package/src/domain/services/shell_filesystem_inference.ts +360 -0
  44. package/src/engine/approval_fsm.ts +104 -0
  45. package/src/engine/decision_engine.ts +39 -0
  46. package/src/engine/dlp_engine.ts +91 -0
  47. package/src/engine/rule_engine.ts +208 -0
  48. package/src/events/emitter.ts +86 -0
  49. package/src/events/schema.ts +27 -0
  50. package/src/hooks/context_guard.ts +36 -0
  51. package/src/hooks/output_guard.ts +66 -0
  52. package/src/hooks/persist_guard.ts +69 -0
  53. package/src/hooks/policy_guard.ts +222 -0
  54. package/src/hooks/result_guard.ts +88 -0
  55. package/src/i18n/locale.ts +36 -0
  56. package/src/index.ts +255 -0
  57. package/src/infrastructure/adapters/notification_adapter.ts +173 -0
  58. package/src/infrastructure/adapters/openclaw_adapter_impl.ts +59 -0
  59. package/src/infrastructure/config/plugin_config_parser.ts +105 -0
  60. package/src/monitoring/status_store.ts +612 -0
  61. package/src/types.ts +409 -0
  62. package/src/utils.ts +97 -0
package/install.sh ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PACKAGE="${SECURITYCLAW_NPM_PACKAGE:-securityclaw}"
5
+ VERSION="${SECURITYCLAW_VERSION:-latest}"
6
+
7
+ if command -v npx >/dev/null 2>&1; then
8
+ if [[ "${VERSION}" == "latest" ]]; then
9
+ exec npx --yes "${PACKAGE}" install "$@"
10
+ fi
11
+ exec npx --yes "${PACKAGE}@${VERSION}" install "$@"
12
+ fi
13
+
14
+ if command -v npm >/dev/null 2>&1; then
15
+ if [[ "${VERSION}" == "latest" ]]; then
16
+ exec npm exec --yes --package "${PACKAGE}" securityclaw -- install "$@"
17
+ fi
18
+ exec npm exec --yes --package "${PACKAGE}@${VERSION}" securityclaw -- install "$@"
19
+ fi
20
+
21
+ echo "SecurityClaw installer requires npx or npm." >&2
22
+ exit 1
@@ -0,0 +1,60 @@
1
+ {
2
+ "id": "securityclaw",
3
+ "name": "SecurityClaw Security",
4
+ "description": "Runtime policy enforcement, transcript sanitization, and audit events for OpenClaw.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "configPath": {
10
+ "type": "string",
11
+ "description": "Path to the SecurityClaw YAML policy file, relative to the plugin root or absolute."
12
+ },
13
+ "overridePath": {
14
+ "type": "string",
15
+ "description": "Legacy JSON override file path for one-time migration into SQLite; no longer used for ongoing strategy persistence."
16
+ },
17
+ "dbPath": {
18
+ "type": "string",
19
+ "description": "Optional absolute SQLite override for internal use. Relative values are ignored; normal installs use extensions/securityclaw/data/securityclaw.db."
20
+ },
21
+ "webhookUrl": {
22
+ "type": "string",
23
+ "description": "Optional override for SecurityDecisionEvent webhook delivery."
24
+ },
25
+ "policyVersion": {
26
+ "type": "string"
27
+ },
28
+ "environment": {
29
+ "type": "string"
30
+ },
31
+ "approvalTtlSeconds": {
32
+ "type": "number",
33
+ "minimum": 1
34
+ },
35
+ "persistMode": {
36
+ "type": "string",
37
+ "enum": ["strict", "compat"]
38
+ },
39
+ "decisionLogMaxLength": {
40
+ "type": "number",
41
+ "minimum": 64,
42
+ "description": "Max serialized argument length included in SecurityClaw decision logs."
43
+ },
44
+ "statusPath": {
45
+ "type": "string",
46
+ "description": "Optional absolute status snapshot override for internal use. Relative values are ignored; normal installs use extensions/securityclaw/runtime/securityclaw-status.json."
47
+ },
48
+ "adminAutoStart": {
49
+ "type": "boolean",
50
+ "description": "Whether to auto-start SecurityClaw admin dashboard when plugin is loaded (default true)."
51
+ },
52
+ "adminPort": {
53
+ "type": "number",
54
+ "minimum": 1,
55
+ "maximum": 65535,
56
+ "description": "Optional admin dashboard listen port. Defaults to SECURITYCLAW_ADMIN_PORT or 4780."
57
+ }
58
+ }
59
+ }
60
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "securityclaw",
3
+ "version": "0.0.1",
4
+ "description": "SecurityClaw security plugin for OpenClaw-compatible hooks.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "securityclaw": "bin/securityclaw.mjs"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/znary/securityclaw.git"
16
+ },
17
+ "homepage": "https://github.com/znary/securityclaw#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/znary/securityclaw/issues"
20
+ },
21
+ "openclaw": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ },
26
+ "files": [
27
+ "CHANGELOG.md",
28
+ "LICENSE",
29
+ "README.md",
30
+ "README.zh-CN.md",
31
+ "admin/public",
32
+ "admin/server.ts",
33
+ "bin",
34
+ "config/policy.default.yaml",
35
+ "index.ts",
36
+ "install.sh",
37
+ "openclaw.plugin.json",
38
+ "src"
39
+ ],
40
+ "scripts": {
41
+ "typecheck": "tsc -p tsconfig.json --noEmit",
42
+ "test:unit": "node --test --experimental-strip-types",
43
+ "test": "npm run typecheck && npm run test:unit",
44
+ "check": "npm test",
45
+ "admin": "node --experimental-strip-types ./admin/server.ts",
46
+ "admin:build": "node ./scripts/build-admin.mjs",
47
+ "prepack": "npm test && npm run admin:build",
48
+ "pack:plugin": "node ./scripts/pack-plugin.mjs",
49
+ "openclaw:install": "node ./scripts/install-openclaw-plugin.mjs",
50
+ "release:check": "node ./scripts/release-preflight.mjs",
51
+ "release:dry-run": "npm run release:check && npm publish --dry-run --access public"
52
+ },
53
+ "dependencies": {
54
+ "esbuild": "^0.27.4",
55
+ "react": "^19.2.4",
56
+ "react-dom": "^19.2.4",
57
+ "recharts": "^3.8.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^25.5.0",
61
+ "@types/react": "^19.2.14",
62
+ "@types/react-dom": "^19.2.3",
63
+ "openclaw": "^2026.3.13",
64
+ "typescript": "^5.9.3"
65
+ },
66
+ "peerDependencies": {
67
+ "openclaw": "^2026.3.13"
68
+ }
69
+ }
@@ -0,0 +1,113 @@
1
+ import { build } from "esbuild";
2
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ type AdminBuildLogger = {
7
+ info?: (message: string) => void;
8
+ warn?: (message: string) => void;
9
+ };
10
+
11
+ type AdminBuildPaths = {
12
+ sourceDir: string;
13
+ entryPoint: string;
14
+ outfile: string;
15
+ };
16
+
17
+ type AdminBuildResult = {
18
+ state: "built" | "skipped";
19
+ paths: AdminBuildPaths;
20
+ };
21
+
22
+ type AdminBuildOptions = {
23
+ force?: boolean;
24
+ logger?: AdminBuildLogger;
25
+ paths?: Partial<AdminBuildPaths>;
26
+ };
27
+
28
+ type GlobalWithSecurityClawAdminBuild = typeof globalThis & {
29
+ __securityclawAdminBuildPromise?: Promise<AdminBuildResult>;
30
+ };
31
+
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
33
+
34
+ function resolvePaths(overrides: Partial<AdminBuildPaths> = {}): AdminBuildPaths {
35
+ return {
36
+ sourceDir: overrides.sourceDir ?? path.resolve(ROOT, "admin/src"),
37
+ entryPoint: overrides.entryPoint ?? path.resolve(ROOT, "admin/src/app.jsx"),
38
+ outfile: overrides.outfile ?? path.resolve(ROOT, "admin/public/app.js")
39
+ };
40
+ }
41
+
42
+ function newestMtimeMs(target: string): number | undefined {
43
+ if (!existsSync(target)) {
44
+ return undefined;
45
+ }
46
+ const stat = statSync(target);
47
+ if (!stat.isDirectory()) {
48
+ return stat.mtimeMs;
49
+ }
50
+
51
+ let newest = 0;
52
+ for (const entry of readdirSync(target, { withFileTypes: true })) {
53
+ const candidate = newestMtimeMs(path.join(target, entry.name));
54
+ if (candidate !== undefined && candidate > newest) {
55
+ newest = candidate;
56
+ }
57
+ }
58
+ return newest || stat.mtimeMs;
59
+ }
60
+
61
+ export function shouldBuildAdminAssets(options: Pick<AdminBuildOptions, "paths"> = {}): boolean {
62
+ const paths = resolvePaths(options.paths);
63
+ if (!existsSync(paths.outfile)) {
64
+ return true;
65
+ }
66
+ const sourceMtimeMs = newestMtimeMs(paths.sourceDir);
67
+ if (sourceMtimeMs === undefined) {
68
+ return false;
69
+ }
70
+ return sourceMtimeMs > statSync(paths.outfile).mtimeMs;
71
+ }
72
+
73
+ export async function ensureAdminAssetsBuilt(options: AdminBuildOptions = {}): Promise<AdminBuildResult> {
74
+ const paths = resolvePaths(options.paths);
75
+ if (!options.force && !shouldBuildAdminAssets({ paths })) {
76
+ return { state: "skipped", paths };
77
+ }
78
+ if (!existsSync(paths.entryPoint)) {
79
+ throw new Error(`SecurityClaw admin entry point not found: ${paths.entryPoint}`);
80
+ }
81
+
82
+ const state = globalThis as GlobalWithSecurityClawAdminBuild;
83
+ if (state.__securityclawAdminBuildPromise) {
84
+ return state.__securityclawAdminBuildPromise;
85
+ }
86
+
87
+ const logger = options.logger ?? {};
88
+ mkdirSync(path.dirname(paths.outfile), { recursive: true });
89
+
90
+ const promise = build({
91
+ entryPoints: [paths.entryPoint],
92
+ outfile: paths.outfile,
93
+ bundle: true,
94
+ format: "esm",
95
+ target: ["es2022"],
96
+ sourcemap: false,
97
+ minify: true,
98
+ define: {
99
+ "process.env.NODE_ENV": "\"production\""
100
+ }
101
+ }).then(() => {
102
+ logger.info?.(`SecurityClaw admin bundle rebuilt: ${paths.outfile}`);
103
+ return { state: "built" as const, paths };
104
+ }).catch((error) => {
105
+ logger.warn?.(`SecurityClaw admin bundle build failed (${String(error)})`);
106
+ throw error;
107
+ }).finally(() => {
108
+ delete state.__securityclawAdminBuildPromise;
109
+ });
110
+
111
+ state.__securityclawAdminBuildPromise = promise;
112
+ return promise;
113
+ }
@@ -0,0 +1,195 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { resolveSecurityClawStateDir } from "../infrastructure/config/plugin_config_parser.ts";
6
+ import type { SecurityClawLocale } from "../i18n/locale.ts";
7
+ import { pickLocalized } from "../i18n/locale.ts";
8
+
9
+ export type AdminConsoleState = "started" | "already-running" | "service-command";
10
+
11
+ export type AdminConsoleLogger = {
12
+ info?: (message: string) => void;
13
+ warn?: (message: string) => void;
14
+ };
15
+
16
+ export type BrowserOpenResult = {
17
+ ok: boolean;
18
+ command?: string;
19
+ error?: string;
20
+ };
21
+
22
+ export type BrowserOpener = (url: string) => BrowserOpenResult;
23
+
24
+ export type AnnounceAdminConsoleOptions = {
25
+ locale: SecurityClawLocale;
26
+ logger: AdminConsoleLogger;
27
+ url: string;
28
+ state: AdminConsoleState;
29
+ stateDir?: string;
30
+ opener?: BrowserOpener;
31
+ };
32
+
33
+ export type AnnounceAdminConsoleResult = {
34
+ firstRun: boolean;
35
+ openedAutomatically: boolean;
36
+ markerPath?: string;
37
+ };
38
+
39
+ const MARKER_FILE_NAME = "admin-dashboard-opened-v1.json";
40
+ const BANNER_BORDER = "=".repeat(72);
41
+ const GATEWAY_COMMANDS_WITH_ADMIN_BANNER = new Set(["run", "start", "restart", "status"]);
42
+
43
+ function emitLog(logger: AdminConsoleLogger, message: string): void {
44
+ logger.info?.(message);
45
+ }
46
+
47
+ function emitWarn(logger: AdminConsoleLogger, message: string): void {
48
+ logger.warn?.(message);
49
+ }
50
+
51
+ function localize(locale: SecurityClawLocale, zhText: string, enText: string): string {
52
+ return pickLocalized(locale, zhText, enText);
53
+ }
54
+
55
+ export function resolveAdminConsoleMarkerPath(stateDir: string): string {
56
+ return path.join(resolveSecurityClawStateDir(stateDir), MARKER_FILE_NAME);
57
+ }
58
+
59
+ export function buildAdminConsoleBanner(params: {
60
+ locale: SecurityClawLocale;
61
+ url: string;
62
+ state: AdminConsoleState;
63
+ openedAutomatically: boolean;
64
+ }): string[] {
65
+ const { locale, url, state, openedAutomatically } = params;
66
+ const title =
67
+ state === "already-running"
68
+ ? localize(locale, "SecurityClaw 管理后台已在运行", "SecurityClaw admin dashboard is already running")
69
+ : state === "service-command"
70
+ ? localize(locale, "SecurityClaw 管理后台入口", "SecurityClaw admin dashboard entry")
71
+ : localize(locale, "SecurityClaw 管理后台已启动", "SecurityClaw admin dashboard is ready");
72
+ const openHint = openedAutomatically
73
+ ? localize(
74
+ locale,
75
+ "首次启动已自动在默认浏览器中打开。",
76
+ "Opened automatically in your default browser on first startup.",
77
+ )
78
+ : state === "service-command"
79
+ ? localize(
80
+ locale,
81
+ "后台由 OpenClaw gateway 服务托管;如未自动打开,请手动访问下面的链接。",
82
+ "The background OpenClaw gateway service hosts this dashboard; if your browser did not open automatically, open the URL below manually.",
83
+ )
84
+ : localize(
85
+ locale,
86
+ "如未自动打开,请手动访问下面的链接。",
87
+ "If your browser did not open automatically, open the URL below manually.",
88
+ );
89
+
90
+ return [BANNER_BORDER, title, `URL: ${url}`, openHint, BANNER_BORDER];
91
+ }
92
+
93
+ export function shouldAnnounceAdminConsoleForArgv(argv: readonly string[] = process.argv): boolean {
94
+ const gatewayIndex = argv.findIndex((value) => value === "gateway");
95
+ if (gatewayIndex < 0) {
96
+ return false;
97
+ }
98
+
99
+ for (const token of argv.slice(gatewayIndex + 1)) {
100
+ if (GATEWAY_COMMANDS_WITH_ADMIN_BANNER.has(token)) {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+
107
+ function writeAdminConsoleMarker(markerPath: string, url: string): void {
108
+ mkdirSync(path.dirname(markerPath), { recursive: true });
109
+ writeFileSync(
110
+ markerPath,
111
+ JSON.stringify(
112
+ {
113
+ opened_at: new Date().toISOString(),
114
+ url,
115
+ },
116
+ null,
117
+ 2,
118
+ ),
119
+ "utf8",
120
+ );
121
+ }
122
+
123
+ export function openAdminConsoleInBrowser(url: string): BrowserOpenResult {
124
+ if (process.platform === "darwin") {
125
+ const result = spawnSync("open", [url], { stdio: "ignore", timeout: 5_000 });
126
+ if (result.error) {
127
+ return { ok: false, command: "open", error: String(result.error) };
128
+ }
129
+ if (result.status !== 0) {
130
+ return { ok: false, command: "open", error: `exit code ${result.status}` };
131
+ }
132
+ return { ok: true, command: "open" };
133
+ }
134
+
135
+ if (process.platform === "win32") {
136
+ const result = spawnSync("cmd", ["/c", "start", "", url], {
137
+ stdio: "ignore",
138
+ timeout: 5_000,
139
+ windowsHide: true,
140
+ });
141
+ if (result.error) {
142
+ return { ok: false, command: "cmd /c start", error: String(result.error) };
143
+ }
144
+ if (result.status !== 0) {
145
+ return { ok: false, command: "cmd /c start", error: `exit code ${result.status}` };
146
+ }
147
+ return { ok: true, command: "cmd /c start" };
148
+ }
149
+
150
+ if (process.platform === "linux") {
151
+ const result = spawnSync("xdg-open", [url], { stdio: "ignore", timeout: 5_000 });
152
+ if (result.error) {
153
+ return { ok: false, command: "xdg-open", error: String(result.error) };
154
+ }
155
+ if (result.status !== 0) {
156
+ return { ok: false, command: "xdg-open", error: `exit code ${result.status}` };
157
+ }
158
+ return { ok: true, command: "xdg-open" };
159
+ }
160
+
161
+ return {
162
+ ok: false,
163
+ error: `unsupported platform ${process.platform}`,
164
+ };
165
+ }
166
+
167
+ export function announceAdminConsole(options: AnnounceAdminConsoleOptions): AnnounceAdminConsoleResult {
168
+ const { locale, logger, url, state, stateDir, opener = openAdminConsoleInBrowser } = options;
169
+ const markerPath = stateDir ? resolveAdminConsoleMarkerPath(stateDir) : undefined;
170
+ const firstRun = markerPath !== undefined && !existsSync(markerPath);
171
+
172
+ let openedAutomatically = false;
173
+ if (firstRun) {
174
+ const result = opener(url);
175
+ if (result.ok) {
176
+ openedAutomatically = true;
177
+ if (markerPath) {
178
+ writeAdminConsoleMarker(markerPath, url);
179
+ }
180
+ } else {
181
+ const via = result.command ? ` via ${result.command}` : "";
182
+ emitWarn(logger, `securityclaw: failed to auto-open admin dashboard${via} (${result.error ?? "unknown error"})`);
183
+ }
184
+ }
185
+
186
+ for (const line of buildAdminConsoleBanner({ locale, url, state, openedAutomatically })) {
187
+ emitLog(logger, line);
188
+ }
189
+
190
+ return {
191
+ firstRun,
192
+ openedAutomatically,
193
+ ...(markerPath !== undefined ? { markerPath } : {}),
194
+ };
195
+ }
@@ -0,0 +1,80 @@
1
+ export const ADMIN_TAB_IDS = ["overview", "events", "rules", "skills", "accounts"] as const;
2
+ export const ADMIN_DECISION_FILTER_IDS = ["all", "allow", "warn", "challenge", "block"] as const;
3
+
4
+ export type AdminTabId = (typeof ADMIN_TAB_IDS)[number];
5
+ export type AdminDecisionFilterId = (typeof ADMIN_DECISION_FILTER_IDS)[number];
6
+
7
+ export type AdminDashboardUrlState = {
8
+ tab: AdminTabId;
9
+ decisionFilter: AdminDecisionFilterId;
10
+ decisionPage: number;
11
+ };
12
+
13
+ const ADMIN_TAB_ID_SET = new Set<string>(ADMIN_TAB_IDS);
14
+ const ADMIN_DECISION_FILTER_ID_SET = new Set<string>(ADMIN_DECISION_FILTER_IDS);
15
+
16
+ function readPositivePage(value: string | null | undefined): number {
17
+ const page = Number.parseInt(String(value || ""), 10);
18
+ return Number.isFinite(page) && page > 0 ? page : 1;
19
+ }
20
+
21
+ export function normalizeAdminTabId(value: string | null | undefined): AdminTabId {
22
+ return ADMIN_TAB_ID_SET.has(String(value || "")) ? (value as AdminTabId) : "overview";
23
+ }
24
+
25
+ export function normalizeAdminDecisionFilterId(value: string | null | undefined): AdminDecisionFilterId {
26
+ return ADMIN_DECISION_FILTER_ID_SET.has(String(value || "")) ? (value as AdminDecisionFilterId) : "all";
27
+ }
28
+
29
+ export function matchesAdminDecisionFilter(
30
+ decision: string | null | undefined,
31
+ filter: AdminDecisionFilterId
32
+ ): boolean {
33
+ if (filter === "all") {
34
+ return true;
35
+ }
36
+ return decision === filter;
37
+ }
38
+
39
+ export function readAdminDashboardUrlState(input: {
40
+ search?: string;
41
+ hash?: string;
42
+ } = {}): AdminDashboardUrlState {
43
+ const searchParams = new URLSearchParams(input.search || "");
44
+ const hashTab = (input.hash || "").replace(/^#/, "");
45
+ return {
46
+ tab: normalizeAdminTabId(searchParams.get("tab") || hashTab),
47
+ decisionFilter: normalizeAdminDecisionFilterId(searchParams.get("decision")),
48
+ decisionPage: readPositivePage(searchParams.get("page"))
49
+ };
50
+ }
51
+
52
+ export function buildAdminDashboardSearch(input: {
53
+ currentSearch?: string;
54
+ tab: AdminTabId;
55
+ decisionFilter: AdminDecisionFilterId;
56
+ decisionPage: number;
57
+ }): string {
58
+ const searchParams = new URLSearchParams(input.currentSearch || "");
59
+
60
+ if (input.tab === "overview") {
61
+ searchParams.delete("tab");
62
+ } else {
63
+ searchParams.set("tab", input.tab);
64
+ }
65
+
66
+ if (input.decisionFilter === "all") {
67
+ searchParams.delete("decision");
68
+ } else {
69
+ searchParams.set("decision", input.decisionFilter);
70
+ }
71
+
72
+ if (input.tab !== "events" || input.decisionPage <= 1) {
73
+ searchParams.delete("page");
74
+ } else {
75
+ searchParams.set("page", String(input.decisionPage));
76
+ }
77
+
78
+ const serialized = searchParams.toString();
79
+ return serialized ? `?${serialized}` : "";
80
+ }
@@ -0,0 +1,137 @@
1
+ import os from "node:os";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { ApprovalSubjectResolver } from "../domain/services/approval_subject_resolver.ts";
6
+
7
+ type JsonRecord = Record<string, unknown>;
8
+
9
+ type RawSessionMetadata = {
10
+ sessionId?: unknown;
11
+ updatedAt?: unknown;
12
+ chatType?: unknown;
13
+ lastChannel?: unknown;
14
+ deliveryContext?: {
15
+ channel?: unknown;
16
+ };
17
+ origin?: {
18
+ provider?: unknown;
19
+ surface?: unknown;
20
+ chatType?: unknown;
21
+ };
22
+ sessionFile?: unknown;
23
+ };
24
+
25
+ export type OpenClawChatSession = {
26
+ subject: string;
27
+ label: string;
28
+ session_key: string;
29
+ session_id?: string;
30
+ agent_id?: string;
31
+ channel?: string;
32
+ provider?: string;
33
+ chat_type?: string;
34
+ updated_at?: string;
35
+ session_file?: string;
36
+ };
37
+
38
+ function normalizeString(value: unknown): string | undefined {
39
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
40
+ }
41
+
42
+ function normalizeTimestamp(value: unknown): string | undefined {
43
+ if (typeof value === "number" && Number.isFinite(value)) {
44
+ return new Date(value).toISOString();
45
+ }
46
+ if (typeof value === "string" && value.trim()) {
47
+ const parsed = Date.parse(value);
48
+ if (Number.isFinite(parsed)) {
49
+ return new Date(parsed).toISOString();
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ function readSessionFile(filePath: string): JsonRecord {
56
+ const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
57
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
58
+ return {};
59
+ }
60
+ return raw as JsonRecord;
61
+ }
62
+
63
+ export function listOpenClawChatSessions(openClawHome = path.join(os.homedir(), ".openclaw")): OpenClawChatSession[] {
64
+ const agentsDir = path.join(openClawHome, "agents");
65
+ if (!existsSync(agentsDir)) {
66
+ return [];
67
+ }
68
+
69
+ const deduped = new Map<string, OpenClawChatSession>();
70
+ for (const agentEntry of readdirSync(agentsDir, { withFileTypes: true })) {
71
+ if (!agentEntry.isDirectory()) {
72
+ continue;
73
+ }
74
+
75
+ const agentId = agentEntry.name;
76
+ const sessionsPath = path.join(agentsDir, agentId, "sessions", "sessions.json");
77
+ if (!existsSync(sessionsPath)) {
78
+ continue;
79
+ }
80
+
81
+ const rawSessions = readSessionFile(sessionsPath);
82
+ for (const [sessionKey, rawMetadata] of Object.entries(rawSessions)) {
83
+ if (!rawMetadata || typeof rawMetadata !== "object" || Array.isArray(rawMetadata)) {
84
+ continue;
85
+ }
86
+
87
+ const metadata = rawMetadata as RawSessionMetadata;
88
+ const sessionId = normalizeString(metadata.sessionId);
89
+ const channel =
90
+ normalizeString(metadata.deliveryContext?.channel) ??
91
+ normalizeString(metadata.lastChannel);
92
+ const provider =
93
+ normalizeString(metadata.origin?.provider) ??
94
+ normalizeString(metadata.origin?.surface);
95
+ const chatType =
96
+ normalizeString(metadata.chatType) ??
97
+ normalizeString(metadata.origin?.chatType);
98
+ const updatedAt = normalizeTimestamp(metadata.updatedAt);
99
+ const sessionFile = normalizeString(metadata.sessionFile);
100
+ const subject = ApprovalSubjectResolver.resolve({
101
+ agentId,
102
+ sessionKey,
103
+ ...(sessionId ? { sessionId } : {}),
104
+ ...(channel ? { channelId: channel } : {})
105
+ });
106
+
107
+ const entry: OpenClawChatSession = {
108
+ subject,
109
+ label: subject,
110
+ session_key: sessionKey,
111
+ ...(sessionId ? { session_id: sessionId } : {}),
112
+ ...(channel ? { channel } : {}),
113
+ ...(provider ? { provider } : {}),
114
+ ...(chatType ? { chat_type: chatType } : {}),
115
+ ...(updatedAt ? { updated_at: updatedAt } : {}),
116
+ ...(sessionFile ? { session_file: sessionFile } : {}),
117
+ agent_id: agentId
118
+ };
119
+
120
+ const previous = deduped.get(subject);
121
+ const previousTs = previous?.updated_at ? Date.parse(previous.updated_at) : Number.NEGATIVE_INFINITY;
122
+ const nextTs = entry.updated_at ? Date.parse(entry.updated_at) : Number.NEGATIVE_INFINITY;
123
+ if (!previous || nextTs >= previousTs) {
124
+ deduped.set(subject, entry);
125
+ }
126
+ }
127
+ }
128
+
129
+ return Array.from(deduped.values()).sort((left, right) => {
130
+ const rightTs = right.updated_at ? Date.parse(right.updated_at) : Number.NEGATIVE_INFINITY;
131
+ const leftTs = left.updated_at ? Date.parse(left.updated_at) : Number.NEGATIVE_INFINITY;
132
+ if (rightTs !== leftTs) {
133
+ return rightTs - leftTs;
134
+ }
135
+ return left.subject.localeCompare(right.subject);
136
+ });
137
+ }