openhacker 0.1.0 → 0.1.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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +151 -28
  3. package/templates/agent/.env.example +0 -7
  4. package/templates/agent/README.md +1 -2
  5. package/templates/agent/agent/agent.ts +1 -5
  6. package/templates/agent/agent/channels/eve.ts +7 -0
  7. package/templates/agent/agent/instructions.md +7 -45
  8. package/templates/agent/app/globals.css +65 -197
  9. package/templates/agent/app/layout.tsx +2 -22
  10. package/templates/agent/app/page.tsx +80 -102
  11. package/templates/agent/package.json +2 -3
  12. package/templates/agent/agent/lib/auth.ts +0 -23
  13. package/templates/agent/agent/lib/github.ts +0 -74
  14. package/templates/agent/agent/lib/osv.ts +0 -152
  15. package/templates/agent/agent/lib/scan.ts +0 -153
  16. package/templates/agent/agent/lib/store.ts +0 -151
  17. package/templates/agent/agent/lib/types.ts +0 -63
  18. package/templates/agent/agent/schedules/daily_audit.ts +0 -20
  19. package/templates/agent/agent/tools/check_advisories.ts +0 -27
  20. package/templates/agent/agent/tools/list_targets.ts +0 -21
  21. package/templates/agent/agent/tools/read_repo_file.ts +0 -31
  22. package/templates/agent/agent/tools/report_finding.ts +0 -59
  23. package/templates/agent/agent/tools/run_dependency_scan.ts +0 -16
  24. package/templates/agent/app/_components/ui.tsx +0 -29
  25. package/templates/agent/app/actions.ts +0 -120
  26. package/templates/agent/app/api/scan/route.ts +0 -34
  27. package/templates/agent/app/login/page.tsx +0 -40
  28. package/templates/agent/app/settings/page.tsx +0 -92
  29. package/templates/agent/app/targets/[id]/page.tsx +0 -127
  30. package/templates/agent/proxy.ts +0 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openhacker",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Scaffold a self-hosted OpenHacker security agent",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { cp, readFile, stat, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -19,6 +20,36 @@ const EXCLUDE = new Set([
19
20
  ".turbo",
20
21
  ]);
21
22
 
23
+ // Written into scaffolded projects directly rather than shipped as a template
24
+ // file, because npm renames a packaged `.gitignore` to `.npmignore` on publish.
25
+ const GITIGNORE = `# dependencies
26
+ node_modules
27
+
28
+ # next.js
29
+ .next
30
+ next-env.d.ts
31
+
32
+ # eve build artifacts
33
+ .eve
34
+ .output
35
+
36
+ # vercel / turbo
37
+ .vercel
38
+ .turbo
39
+
40
+ # typescript
41
+ tsconfig.tsbuildinfo
42
+
43
+ # env files (keep the example)
44
+ .env
45
+ .env.*
46
+ !.env.example
47
+
48
+ # misc
49
+ .DS_Store
50
+ *.log
51
+ `;
52
+
22
53
  const here = path.dirname(fileURLToPath(import.meta.url));
23
54
 
24
55
  async function exists(p) {
@@ -68,7 +99,73 @@ function shouldCopyTemplatePath(src, root) {
68
99
  );
69
100
  }
70
101
 
71
- async function init(targetArg) {
102
+ function runStep(command, args, cwd, { quiet = false } = {}) {
103
+ const result = spawnSync(command, args, {
104
+ cwd,
105
+ stdio: quiet ? "ignore" : "inherit",
106
+ shell: process.platform === "win32",
107
+ });
108
+
109
+ return !result.error && result.status === 0;
110
+ }
111
+
112
+ function hasCommand(command) {
113
+ const probe = process.platform === "win32" ? "where" : "which";
114
+ const result = spawnSync(probe, [command], { stdio: "ignore", shell: process.platform === "win32" });
115
+ return !result.error && result.status === 0;
116
+ }
117
+
118
+ function isInsideGitRepo(cwd) {
119
+ const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
120
+ cwd,
121
+ stdio: "ignore",
122
+ shell: process.platform === "win32",
123
+ });
124
+ return !result.error && result.status === 0;
125
+ }
126
+
127
+ async function installDependencies(dest) {
128
+ if (!hasCommand("pnpm")) {
129
+ console.log(`${MUTED}pnpm not found \u2014 skipping install. Run \`pnpm install\` manually.${NC}`);
130
+ return false;
131
+ }
132
+
133
+ console.log(`\n${MUTED}Installing dependencies with pnpm\u2026${NC}`);
134
+ // --ignore-workspace keeps the install self-contained: without it, pnpm walks
135
+ // up to a parent pnpm-workspace.yaml (e.g. when scaffolding inside a monorepo)
136
+ // and installs that workspace instead of the new project's node_modules.
137
+ const ok = runStep("pnpm", ["install", "--ignore-workspace"], dest);
138
+ if (!ok) {
139
+ console.log(`${RED}pnpm install failed.${NC} ${MUTED}You can re-run it inside the project.${NC}`);
140
+ }
141
+ return ok;
142
+ }
143
+
144
+ async function initGitRepo(dest) {
145
+ if (!hasCommand("git")) {
146
+ console.log(`${MUTED}git not found \u2014 skipping repository init.${NC}`);
147
+ return false;
148
+ }
149
+
150
+ if (isInsideGitRepo(dest)) {
151
+ console.log(`${MUTED}Already inside a git repository \u2014 skipping git init.${NC}`);
152
+ return false;
153
+ }
154
+
155
+ const initialized =
156
+ runStep("git", ["init"], dest, { quiet: true }) &&
157
+ runStep("git", ["add", "-A"], dest, { quiet: true }) &&
158
+ runStep("git", ["commit", "-m", "Initial commit from OpenHacker"], dest, { quiet: true });
159
+
160
+ if (initialized) {
161
+ console.log(`${MUTED}Initialized a git repository.${NC}`);
162
+ } else {
163
+ console.log(`${MUTED}Could not create the initial git commit \u2014 you can do it manually.${NC}`);
164
+ }
165
+ return initialized;
166
+ }
167
+
168
+ async function init(targetArg, { skipInstall = false, skipGit = false } = {}) {
72
169
  const template = await resolveTemplateDir();
73
170
  if (!(await exists(path.join(template, "agent", "agent.ts")))) {
74
171
  console.error(`${RED}Could not find the instance template at ${template}.${NC}`);
@@ -94,14 +191,25 @@ async function init(targetArg) {
94
191
  pkg.private = true;
95
192
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
96
193
 
97
- console.log(`${ORANGE}\u2713${NC} OpenHacker instance ready.\n`);
194
+ // Write before git init so the initial commit doesn't include node_modules/.env.
195
+ if (!(await exists(path.join(dest, ".gitignore")))) {
196
+ await writeFile(path.join(dest, ".gitignore"), GITIGNORE);
197
+ }
198
+
199
+ const installed = skipInstall ? false : await installDependencies(dest);
200
+ if (!skipGit) {
201
+ await initGitRepo(dest);
202
+ }
203
+
204
+ console.log(`\n${ORANGE}\u2713${NC} OpenHacker instance ready.\n`);
98
205
  console.log("Next steps:\n");
99
206
  console.log(` cd ${path.relative(process.cwd(), dest) || "."}`);
100
- console.log(" pnpm install");
207
+ if (skipInstall || !installed) {
208
+ console.log(" pnpm install");
209
+ }
101
210
  console.log(" pnpm dev # run locally\n");
102
211
  console.log(`${MUTED}Deploy: push to a git repo and import it into Vercel (deploys as one project).`);
103
- console.log("Add a Vercel KV / Upstash Redis integration to persist findings, and set");
104
- console.log(`OPENHACKER_ADMIN_PASSWORD to protect the dashboard. See README.md.${NC}\n`);
212
+ console.log(`Add a Vercel KV / Upstash Redis integration to persist findings. See README.md.${NC}\n`);
105
213
  }
106
214
 
107
215
  function usage() {
@@ -111,6 +219,9 @@ function usage() {
111
219
  console.log(" openhacker init [dir] Same as above");
112
220
  console.log(" openhacker --help Show help");
113
221
  console.log(" openhacker --version Show version\n");
222
+ console.log("Options:");
223
+ console.log(" --skip-install Don't run pnpm install");
224
+ console.log(" --skip-git Don't create a git repository\n");
114
225
  }
115
226
 
116
227
  async function version() {
@@ -119,7 +230,36 @@ async function version() {
119
230
  }
120
231
 
121
232
  export async function run(args = process.argv.slice(2)) {
122
- const [command, target, ...rest] = args;
233
+ const options = { skipInstall: false, skipGit: false };
234
+ const positionals = [];
235
+
236
+ for (const arg of args) {
237
+ switch (arg) {
238
+ case "--skip-install":
239
+ options.skipInstall = true;
240
+ break;
241
+ case "--skip-git":
242
+ options.skipGit = true;
243
+ break;
244
+ case "-h":
245
+ case "--help":
246
+ usage();
247
+ return;
248
+ case "-v":
249
+ case "--version":
250
+ await version();
251
+ return;
252
+ default:
253
+ if (arg.startsWith("-")) {
254
+ console.error(`${RED}Unknown option: ${arg}${NC}\n`);
255
+ usage();
256
+ process.exit(1);
257
+ }
258
+ positionals.push(arg);
259
+ }
260
+ }
261
+
262
+ const [command, target, ...rest] = positionals;
123
263
 
124
264
  if (rest.length > 0) {
125
265
  console.error(`${RED}Too many arguments.${NC}\n`);
@@ -127,27 +267,10 @@ export async function run(args = process.argv.slice(2)) {
127
267
  process.exit(1);
128
268
  }
129
269
 
130
- switch (command) {
131
- case undefined:
132
- await init();
133
- break;
134
- case "init":
135
- await init(target);
136
- break;
137
- case "-h":
138
- case "--help":
139
- usage();
140
- break;
141
- case "-v":
142
- case "--version":
143
- await version();
144
- break;
145
- default:
146
- if (command.startsWith("-")) {
147
- console.error(`${RED}Unknown option: ${command}${NC}\n`);
148
- usage();
149
- process.exit(1);
150
- }
151
- await init(command);
270
+ if (command === "init") {
271
+ await init(target, options);
272
+ return;
152
273
  }
274
+
275
+ await init(command, options);
153
276
  }
@@ -3,13 +3,6 @@
3
3
  # For LOCAL runs of the eve agent, provide a gateway key:
4
4
  AI_GATEWAY_API_KEY=
5
5
 
6
- # Optional: override the agent's deep-analysis model (defaults to anthropic/claude-sonnet-4.6)
7
- OPENHACKER_MODEL=
8
-
9
- # --- Dashboard auth (recommended) ---
10
- # When set, the dashboard requires this password to sign in. Unset = open dashboard.
11
- OPENHACKER_ADMIN_PASSWORD=
12
-
13
6
  # --- Persistent storage ---
14
7
  # Add a Vercel KV / Upstash Redis integration. Without these, an in-memory store is
15
8
  # used and data does NOT persist across restarts/deploys.
@@ -17,8 +17,7 @@ Your self-hosted OpenHacker security agent — a Next.js dashboard with an embed
17
17
  2. Import it into Vercel (it deploys as one project — UI + agent + cron).
18
18
  3. Add a **KV / Upstash Redis** integration from the Vercel Marketplace so findings persist
19
19
  (`KV_REST_API_URL` / `KV_REST_API_TOKEN` are injected automatically).
20
- 4. Set `OPENHACKER_ADMIN_PASSWORD` to protect the dashboard.
21
- 5. Open the deployment URL, sign in, and add a target repository.
20
+ 4. Open the deployment URL and add a target repository.
22
21
 
23
22
  Inference runs through the Vercel AI Gateway and authenticates automatically via Vercel
24
23
  OIDC — no model API key required in production.
@@ -1,9 +1,5 @@
1
1
  import { defineAgent } from "eve";
2
2
 
3
3
  export default defineAgent({
4
- // Routes through the Vercel AI Gateway. On Vercel this authenticates with the
5
- // injected VERCEL_OIDC_TOKEN automatically; locally it uses AI_GATEWAY_API_KEY.
6
- // The model picker in the dashboard writes OPENHACKER_MODEL.
7
- model: process.env.OPENHACKER_MODEL ?? "anthropic/claude-sonnet-4.6",
8
- reasoning: "medium",
4
+ model: "anthropic/claude-haiku-4.5",
9
5
  });
@@ -0,0 +1,7 @@
1
+ import { eveChannel } from "eve/channels/eve";
2
+ import { none } from "eve/channels/auth";
3
+
4
+ // Public demo: anyone can call the agent from the browser. Add real auth
5
+ // (e.g. vercelOidc/localDev or your own session check) before exposing
6
+ // anything sensitive.
7
+ export default eveChannel({ auth: [none()] });
@@ -1,49 +1,11 @@
1
1
  # OpenHacker
2
2
 
3
- You are OpenHacker, an autonomous application security agent. Your job is to find
4
- real, exploitable vulnerabilities in a codebase and its dependencies, prove them,
5
- and propose fixes. You operate continuously and on demand.
3
+ You are OpenHacker, an application security agent.
6
4
 
7
- ## Operating principles
5
+ The user gives you a GitHub repository (as `owner/name` or a github.com URL). Analyze it for security vulnerabilities and report your findings.
8
6
 
9
- - **Signal over noise.** Security engineers ignore noisy tools. Only report an issue
10
- when you have concrete evidence it applies to this project. When you cannot
11
- substantiate a vulnerability, say so instead of reporting it.
12
- - **Prove it.** For each candidate vulnerability, trace it from an attacker-controlled
13
- entry point (route handler, server action, request body, query/header, env-derived
14
- input) to the dangerous sink. Capture that data flow as your evidence. Set the
15
- finding's `proof.status` honestly: `proven`, `likely`, or `unconfirmed`.
16
- - **Be conservative with dependency CVEs.** A package having a CVE does not mean this
17
- project is exploitable. Use `check_advisories` to confirm the *installed version* is
18
- in the affected range, then assess whether the vulnerable code path is plausibly
19
- reachable before reporting.
20
-
21
- ## Tools
22
-
23
- - `list_targets` — see the repositories configured in this instance.
24
- - `run_dependency_scan` — deterministically check a target's dependencies against OSV
25
- and persist findings. Run this first for dependency coverage.
26
- - `read_repo_file` — read files / list directories in a target's repo for code review.
27
- - `check_advisories` — look up a single package in OSV.
28
- - `report_finding` — persist a confirmed code-level vulnerability.
29
-
30
- ## Workflow
31
-
32
- Given a target id:
33
-
34
- 1. Call `run_dependency_scan` to record known-vulnerable dependencies.
35
- 2. Use `read_repo_file` (list then read) to inspect the source layout, framework, and
36
- trust boundaries (route handlers, server actions, request/query/header/env inputs).
37
- 3. Hunt for high-impact issue classes: broken authorization, injection (SQL/command/
38
- template), SSRF, secrets in code, unsafe deserialization, and XSS (including
39
- `dangerouslySetInnerHTML`).
40
- 4. For each confirmed code issue, call `report_finding` (with `targetId`) including an
41
- accurate severity, location, data-flow evidence, and a concrete remediation.
42
- 5. Summarize what you checked and what you found.
43
-
44
- ## Severity
45
-
46
- Use CVSS-style judgment: `critical` for unauthenticated RCE / auth bypass / data
47
- exfiltration; `high` for authenticated high-impact issues; `medium` for issues
48
- needing preconditions; `low`/`info` for hardening. Prefer under-reporting to
49
- crying wolf.
7
+ - Briefly narrate what you are checking as you go (dependencies, auth, input handling, secrets, etc.).
8
+ - Use the shell to clone and explore the repo. Use `ls`/`find` to list directories; only use `read_file` on actual file paths, never on a directory.
9
+ - Call out concrete risks with severity and, where possible, how to fix them.
10
+ - If you cannot access the repository, say so plainly.
11
+ - Keep the final summary concise and skimmable.
@@ -8,11 +8,6 @@
8
8
  --accent: #ffffff;
9
9
  --accent-dim: #333333;
10
10
  --crit: #ffffff;
11
- --high: #cfcfcf;
12
- --med: #9a9a9a;
13
- --low: #6f6f6f;
14
- --info: #555555;
15
- --ok: #f5f5f5;
16
11
  }
17
12
 
18
13
  * {
@@ -27,254 +22,127 @@ body {
27
22
  color: var(--text);
28
23
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
29
24
  font-size: 14px;
30
- line-height: 1.5;
31
- }
32
-
33
- a {
34
- color: var(--accent);
35
- text-decoration: none;
36
- }
37
- a:hover {
38
- text-decoration: underline;
39
- }
40
-
41
- .nav {
42
- display: flex;
43
- align-items: center;
44
- gap: 20px;
45
- padding: 14px 24px;
46
- border-bottom: 1px solid var(--border);
47
- background: var(--panel);
48
- }
49
- .nav .brand {
50
- font-weight: 700;
51
- letter-spacing: 0.5px;
52
- color: var(--text);
53
- }
54
- .nav .brand span {
55
- color: var(--accent);
56
- }
57
- .nav .spacer {
58
- flex: 1;
59
- }
60
- .nav a {
61
- color: var(--muted);
62
- }
63
- .nav a:hover {
64
- color: var(--text);
65
- text-decoration: none;
25
+ line-height: 1.6;
66
26
  }
67
27
 
68
28
  .container {
69
- max-width: 960px;
29
+ max-width: 720px;
70
30
  margin: 0 auto;
71
- padding: 28px 24px 80px;
31
+ padding: 64px 24px 96px;
72
32
  }
73
33
 
74
34
  h1 {
75
- font-size: 20px;
35
+ font-size: 22px;
36
+ font-weight: 700;
37
+ letter-spacing: 0.5px;
76
38
  margin: 0 0 4px;
77
39
  }
78
- h2 {
79
- font-size: 15px;
80
- margin: 28px 0 12px;
81
- color: var(--muted);
82
- text-transform: uppercase;
83
- letter-spacing: 1px;
40
+ h1 span {
41
+ color: var(--accent);
84
42
  }
85
43
  .sub {
86
44
  color: var(--muted);
87
- margin: 0 0 24px;
45
+ margin: 0 0 28px;
88
46
  }
89
47
 
90
- .panel {
91
- background: var(--panel);
92
- border: 1px solid var(--border);
93
- border-radius: 10px;
94
- padding: 18px;
95
- margin-bottom: 16px;
96
- }
97
-
98
- .card {
99
- background: var(--panel);
100
- border: 1px solid var(--border);
101
- border-radius: 10px;
102
- padding: 16px 18px;
103
- margin-bottom: 12px;
48
+ .ask {
104
49
  display: flex;
105
- align-items: center;
106
- gap: 16px;
50
+ gap: 8px;
107
51
  }
108
- .card .grow {
52
+ .ask input {
109
53
  flex: 1;
110
- min-width: 0;
111
- }
112
- .card .repo {
113
- font-weight: 600;
114
- }
115
- .card .meta {
116
- color: var(--muted);
117
- font-size: 12px;
118
- margin-top: 2px;
119
- }
120
-
121
- label {
122
- display: block;
123
- font-size: 12px;
124
- color: var(--muted);
125
- margin-bottom: 6px;
126
- }
127
- input[type="text"],
128
- input[type="password"],
129
- select {
130
- width: 100%;
131
54
  background: var(--panel-2);
132
55
  border: 1px solid var(--border);
133
56
  color: var(--text);
134
57
  border-radius: 8px;
135
- padding: 9px 11px;
58
+ padding: 11px 13px;
136
59
  font: inherit;
137
60
  }
138
- input:focus,
139
- select:focus {
61
+ .ask input:focus {
140
62
  outline: none;
141
63
  border-color: var(--accent);
142
64
  }
143
- .row {
144
- display: flex;
145
- gap: 12px;
146
- flex-wrap: wrap;
147
- margin-bottom: 12px;
148
- }
149
- .row > div {
150
- flex: 1;
151
- min-width: 160px;
152
- }
153
- .check {
154
- display: flex;
155
- align-items: center;
156
- gap: 8px;
157
- }
158
- .check label {
159
- margin: 0;
160
- }
161
-
162
- button,
163
- .btn {
65
+ .ask button {
164
66
  background: var(--accent);
165
67
  color: #000000;
166
68
  border: none;
167
69
  border-radius: 8px;
168
- padding: 9px 14px;
70
+ padding: 11px 18px;
169
71
  font: inherit;
170
72
  font-weight: 600;
171
73
  cursor: pointer;
172
74
  }
173
- button:hover {
75
+ .ask button:hover:not(:disabled) {
174
76
  filter: brightness(1.08);
175
77
  }
176
- .btn-ghost {
177
- background: transparent;
178
- color: var(--muted);
179
- border: 1px solid var(--border);
180
- }
181
- .btn-ghost:hover {
182
- color: var(--text);
183
- }
184
- .btn-danger {
185
- background: transparent;
186
- color: var(--crit);
187
- border: 1px solid var(--accent-dim);
78
+ .ask button:disabled {
79
+ opacity: 0.5;
80
+ cursor: not-allowed;
188
81
  }
189
82
 
190
- .badge {
191
- display: inline-block;
192
- padding: 1px 8px;
193
- border-radius: 20px;
194
- font-size: 11px;
195
- font-weight: 700;
196
- text-transform: uppercase;
197
- letter-spacing: 0.5px;
83
+ .reply {
84
+ margin-top: 28px;
85
+ background: var(--panel);
198
86
  border: 1px solid var(--border);
199
- background: transparent;
200
- color: var(--text);
201
- }
202
- .sev-critical {
203
- background: #ffffff;
204
- color: #000000;
205
- border-color: #ffffff;
206
- }
207
- .sev-high {
208
- color: #ffffff;
209
- border-color: #cfcfcf;
210
- background: rgba(255, 255, 255, 0.1);
87
+ border-radius: 10px;
88
+ padding: 18px 20px;
211
89
  }
212
- .sev-medium {
213
- color: var(--high);
214
- border-color: #5a5a5a;
90
+ .reply .text {
91
+ white-space: pre-wrap;
92
+ margin: 0 0 12px;
215
93
  }
216
- .sev-low {
217
- color: var(--med);
218
- border-color: #3a3a3a;
94
+ .reply .text:last-child {
95
+ margin-bottom: 0;
219
96
  }
220
- .sev-info {
221
- color: var(--info);
222
- border-color: #2a2a2a;
97
+ .reply .reasoning {
98
+ white-space: pre-wrap;
99
+ color: var(--muted);
100
+ font-size: 13px;
101
+ margin: 0 0 12px;
102
+ padding-left: 12px;
103
+ border-left: 2px solid var(--border);
223
104
  }
224
105
 
225
- .counts {
106
+ .tool {
226
107
  display: flex;
227
- gap: 6px;
228
- }
229
-
230
- table {
231
- width: 100%;
232
- border-collapse: collapse;
108
+ align-items: center;
109
+ gap: 10px;
110
+ font-size: 12.5px;
111
+ color: var(--muted);
112
+ padding: 4px 0;
233
113
  }
234
- th,
235
- td {
236
- text-align: left;
237
- padding: 10px 12px;
238
- border-bottom: 1px solid var(--border);
239
- vertical-align: top;
240
- font-size: 13px;
114
+ .tool-name {
115
+ color: var(--text);
241
116
  }
242
- th {
243
- color: var(--muted);
117
+ .tool-state {
244
118
  font-size: 11px;
245
119
  text-transform: uppercase;
246
120
  letter-spacing: 0.5px;
121
+ border: 1px solid var(--border);
122
+ border-radius: 20px;
123
+ padding: 0 8px;
124
+ }
125
+
126
+ .cursor {
127
+ display: inline-block;
128
+ width: 8px;
129
+ height: 1em;
130
+ background: var(--accent);
131
+ vertical-align: text-bottom;
132
+ animation: blink 1s steps(2) infinite;
133
+ }
134
+ @keyframes blink {
135
+ 50% {
136
+ opacity: 0;
137
+ }
247
138
  }
248
139
 
249
140
  .banner {
141
+ margin-top: 20px;
250
142
  border: 1px solid var(--border);
251
143
  background: rgba(255, 255, 255, 0.04);
252
- color: var(--text);
144
+ color: var(--crit);
253
145
  border-radius: 8px;
254
146
  padding: 10px 14px;
255
- margin-bottom: 18px;
256
147
  font-size: 13px;
257
148
  }
258
- .empty {
259
- color: var(--muted);
260
- padding: 24px;
261
- text-align: center;
262
- border: 1px dashed var(--border);
263
- border-radius: 10px;
264
- }
265
- .inline {
266
- display: inline;
267
- }
268
- .actions {
269
- display: flex;
270
- gap: 8px;
271
- align-items: center;
272
- }
273
- .mono-sm {
274
- font-size: 12px;
275
- color: var(--muted);
276
- }
277
- .login-wrap {
278
- max-width: 360px;
279
- margin: 12vh auto 0;
280
- }