zigmini 0.9.0-beta.3 → 1.0.0-beta.2
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.
- package/README.ko.md +36 -4
- package/README.md +36 -4
- package/assets/agent/gateway-rules.md +29 -19
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +60 -11
- package/docs/manual.md +61 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +97 -13
- package/src/approvals.ts +6 -1
- package/src/cli/doctor.ts +7 -0
- package/src/cli/main.ts +38 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/rules.ts +70 -0
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +172 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
package/src/cli/rules.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { INSTALL_RULES_FILE, PACKAGE_RULES_FILE, ruleMapping, splitRuleItems } from "../agent-rules.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `zigmini rules` lists the package (common) rule items with their number, fixed id and first line.
|
|
7
|
+
* `zigmini rules check` shows how each item of <home>/rules.md maps onto them: it replaces one (by id), it is new, or it
|
|
8
|
+
* names an id the package no longer has (exit 1). The numbers are only a reading order, never a priority.
|
|
9
|
+
*/
|
|
10
|
+
export interface RulesDeps {
|
|
11
|
+
readonly home: string;
|
|
12
|
+
readonly lang: "en" | "ko";
|
|
13
|
+
readonly out: (text: string) => void;
|
|
14
|
+
readonly err: (text: string) => void;
|
|
15
|
+
readonly packageFile?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TEXT = {
|
|
19
|
+
en: { usage: "Usage: zigmini rules [check]", noPackage: "package rules file not found", noInstall: "no rules.md in this installation: nothing to map",
|
|
20
|
+
replaces: (id: string, target: string) => `replaces common ${target} (${id})`, fresh: "new rule (appended after the common rules)",
|
|
21
|
+
unknown: (id: string) => `unknown id ${id}: appended as a new rule; fix or remove the replaces comment` },
|
|
22
|
+
ko: { usage: "사용법: zigmini rules [check]", noPackage: "공통 규칙 파일이 없습니다", noInstall: "이 설치에 rules.md가 없습니다. 매핑할 항목이 없습니다",
|
|
23
|
+
replaces: (id: string, target: string) => `공통 ${target}번(${id})을 대체`, fresh: "신규 규칙(공통 규칙 뒤에 붙음)",
|
|
24
|
+
unknown: (id: string) => `없는 ID ${id}: 신규로 붙습니다. replaces 주석을 고치거나 지우세요` },
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
function firstLine(raw: string): string {
|
|
28
|
+
const line = raw.split("\n")[0]!.replace(/<!--[\s\S]*?-->/g, "").replace(/^\d+\.\s*/, "").trim();
|
|
29
|
+
return line.length > 60 ? `${line.slice(0, 60)}…` : line;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readText(path: string): string | null {
|
|
33
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rulesCommand(argv: readonly string[], deps: RulesDeps): number {
|
|
37
|
+
const text = TEXT[deps.lang];
|
|
38
|
+
if (argv.length > 1 || (argv.length === 1 && argv[0] !== "check")) {
|
|
39
|
+
deps.err(`${text.usage}\n`);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
const pkg = readText(deps.packageFile ?? PACKAGE_RULES_FILE);
|
|
43
|
+
if (pkg === null) {
|
|
44
|
+
deps.err(`${text.noPackage}\n`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
if (argv.length === 0) {
|
|
48
|
+
for (const item of splitRuleItems(pkg).items) deps.out(`${item.number.padStart(2)} ${item.id ?? "------"} ${firstLine(item.raw)}\n`);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const install = readText(join(deps.home, INSTALL_RULES_FILE));
|
|
52
|
+
if (install === null) {
|
|
53
|
+
deps.out(`${text.noInstall}\n`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const mapping = ruleMapping(pkg, install);
|
|
57
|
+
for (const entry of mapping) {
|
|
58
|
+
const state = entry.state === "replaces" ? text.replaces(entry.replaces!, entry.target!) : entry.state === "new" ? text.fresh : text.unknown(entry.replaces!);
|
|
59
|
+
deps.out(`${entry.number.padStart(2)} ${state}\n`);
|
|
60
|
+
}
|
|
61
|
+
return mapping.some((entry) => entry.state === "unknown_id") ? 1 : 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ids named in rules.md that the package rules no longer have (doctor warns about them). */
|
|
65
|
+
export function unknownRuleIds(home: string, packageFile: string = PACKAGE_RULES_FILE): string[] {
|
|
66
|
+
const pkg = readText(packageFile);
|
|
67
|
+
const install = readText(join(home, INSTALL_RULES_FILE));
|
|
68
|
+
if (pkg === null || install === null) return [];
|
|
69
|
+
return ruleMapping(pkg, install).filter((entry) => entry.state === "unknown_id").map((entry) => entry.replaces!);
|
|
70
|
+
}
|
package/src/cli/scan.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { accessSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, userInfo } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
6
|
+
import { runtimePaths, securityBaselinePaths, type Env } from "../runtime/paths.ts";
|
|
7
|
+
import { hashOnlySource, readBaseline, resolveBaselineSource, type BaselineFile } from "../security/baseline.ts";
|
|
8
|
+
import { applyRepoAllow, loadPrivateSet, parseList, parseObjectExceptions, scanGitObjects, scanTracked, type PrivateFinding } from "../security/repo-scan.ts";
|
|
9
|
+
import { findPrivate, hashTerm, HASH_SALT, machineTerms, normalizeTerm, PUBLIC_ACCOUNT, tildeHome, type PrivateSet } from "../security/terms.ts";
|
|
10
|
+
import type { Language } from "./messages.ts";
|
|
11
|
+
|
|
12
|
+
export type ScanExitCode = 0 | 1 | 2 | 3;
|
|
13
|
+
export type ScanScope = "all" | "tracked" | "git";
|
|
14
|
+
export interface ScanOptions {
|
|
15
|
+
readonly repo: string;
|
|
16
|
+
readonly scope: ScanScope;
|
|
17
|
+
readonly devRef?: string;
|
|
18
|
+
readonly gitDir?: string;
|
|
19
|
+
readonly baseline?: string;
|
|
20
|
+
readonly out?: string;
|
|
21
|
+
readonly exceptions?: string;
|
|
22
|
+
readonly json: boolean;
|
|
23
|
+
readonly quiet: boolean;
|
|
24
|
+
readonly syncHashes: boolean;
|
|
25
|
+
readonly prune: boolean;
|
|
26
|
+
readonly help: boolean;
|
|
27
|
+
}
|
|
28
|
+
export class ScanError extends Error {
|
|
29
|
+
constructor(readonly code: string, readonly exitCode: 2 | 3 = 2) { super(code); }
|
|
30
|
+
}
|
|
31
|
+
const USAGE = "zigmini scan [<repo>] [--scope all|tracked|git] [--dev-ref <ref>] [--git-dir <path>] [--baseline <file>] [--json] [--out <file>] [--sync-hashes] [--prune] [--exceptions <file>] [--quiet] [--help]";
|
|
32
|
+
const TEXT = {
|
|
33
|
+
en: { usage: "Usage", invalid: "Invalid argument or combination", error: "Scan error", output: "Report output failed",
|
|
34
|
+
legacy: "Warning: using the legacy private list; import it into the security baseline." },
|
|
35
|
+
ko: { usage: "사용법", invalid: "잘못된 인자 또는 조합", error: "검사 오류", output: "보고 출력 실패",
|
|
36
|
+
legacy: "경고: 기존 비공개 목록을 사용합니다. 보안 기준으로 가져오세요." },
|
|
37
|
+
} as const;
|
|
38
|
+
export function scanHelp(lang: Language): string {
|
|
39
|
+
return `${TEXT[lang].usage}: ${USAGE}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse once. Help takes precedence even over unknown flags and missing values. */
|
|
43
|
+
export function parseScanArgs(argv: readonly string[], cwd = process.cwd(), lang: Language = "en"): ScanOptions | string {
|
|
44
|
+
const defaults: ScanOptions = { repo: resolve(cwd), scope: "all", json: false, quiet: false, syncHashes: false, prune: false, help: false };
|
|
45
|
+
if (argv.includes("--help") || argv.includes("-h")) return { ...defaults, help: true };
|
|
46
|
+
const fail = (flags: string) => `${TEXT[lang].invalid}: ${flags}`;
|
|
47
|
+
const values = new Map<string, string>();
|
|
48
|
+
const switches = new Set<string>();
|
|
49
|
+
let repo: string | undefined;
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const flag = argv[i] ?? "";
|
|
52
|
+
if (["--json", "--quiet", "--sync-hashes", "--prune"].includes(flag)) {
|
|
53
|
+
if (switches.has(flag)) return fail(flag);
|
|
54
|
+
switches.add(flag);
|
|
55
|
+
} else if (["--scope", "--dev-ref", "--git-dir", "--baseline", "--out", "--exceptions"].includes(flag)) {
|
|
56
|
+
if (values.has(flag)) return fail(flag);
|
|
57
|
+
const value = argv[++i];
|
|
58
|
+
if (value === undefined || value === "" || value.startsWith("-") || /[\0\r\n]/.test(value)) return fail(flag);
|
|
59
|
+
values.set(flag, value);
|
|
60
|
+
} else {
|
|
61
|
+
if (flag.startsWith("-")) return fail("unknown option");
|
|
62
|
+
if (repo !== undefined || flag === "" || /[\0\r\n]/.test(flag)) return fail("<repo>");
|
|
63
|
+
repo = resolve(cwd, flag);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const scope = values.get("--scope") ?? "all";
|
|
67
|
+
if (scope !== "all" && scope !== "tracked" && scope !== "git") return fail("--scope");
|
|
68
|
+
const devRef = values.get("--dev-ref");
|
|
69
|
+
if (devRef !== undefined && !["dev", "refs/heads/dev", "origin/dev", "refs/remotes/origin/dev"].includes(devRef)) return fail("--dev-ref");
|
|
70
|
+
const syncHashes = switches.has("--sync-hashes"), prune = switches.has("--prune");
|
|
71
|
+
if (syncHashes && values.has("--scope")) return fail("--sync-hashes --scope");
|
|
72
|
+
if (syncHashes && devRef !== undefined) return fail("--sync-hashes --dev-ref");
|
|
73
|
+
if (scope === "tracked" && devRef !== undefined) return fail("--scope tracked --dev-ref");
|
|
74
|
+
if (prune && !syncHashes) return fail("--prune --sync-hashes");
|
|
75
|
+
if (values.has("--exceptions") && scope === "tracked") return fail("--exceptions --scope tracked");
|
|
76
|
+
if (values.has("--exceptions") && syncHashes) return fail("--exceptions --sync-hashes");
|
|
77
|
+
const path = (flag: string) => { const value = values.get(flag); return value === undefined ? undefined : resolve(cwd, value); };
|
|
78
|
+
return { ...defaults, repo: repo ?? defaults.repo, scope, devRef, gitDir: path("--git-dir"),
|
|
79
|
+
baseline: path("--baseline"), out: path("--out"), exceptions: path("--exceptions"),
|
|
80
|
+
json: switches.has("--json"), quiet: switches.has("--quiet"), syncHashes, prune };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ScanTarget {
|
|
84
|
+
readonly root: string;
|
|
85
|
+
/** Per-worktree metadata, not the common directory: its HEAD selects the requested branch. */
|
|
86
|
+
readonly gitDir: string;
|
|
87
|
+
readonly commonGitDir: string;
|
|
88
|
+
readonly head: string | null;
|
|
89
|
+
readonly bare: boolean;
|
|
90
|
+
readonly exceptions: ReadonlySet<string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Validate inputs without opening a baseline. protectedPaths contains all selectable baseline paths (todo 14). */
|
|
94
|
+
export function prepareScan(options: ScanOptions, protectedPaths: readonly string[] = scanBaselinePaths(options)): ScanTarget {
|
|
95
|
+
const requested = realpathSync(options.repo);
|
|
96
|
+
const git = (args: readonly string[]) => execFileSync("git", [...args], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
97
|
+
const selected = ["-C", requested];
|
|
98
|
+
const gitDir = realpathSync(git([...selected, "rev-parse", "--absolute-git-dir"]));
|
|
99
|
+
const commonGitDir = realpathSync(git([...selected, "rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
100
|
+
const bare = git([...selected, "rev-parse", "--is-bare-repository"]) === "true";
|
|
101
|
+
if (bare && (options.scope !== "git" || options.syncHashes)) throw new ScanError("bare_requires_scope_git");
|
|
102
|
+
const root = bare ? commonGitDir : realpathSync(git([...selected, "rev-parse", "--show-toplevel"]));
|
|
103
|
+
if (options.gitDir !== undefined) {
|
|
104
|
+
const supplied = realpathSync(git(["--git-dir", options.gitDir, "rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
105
|
+
if (supplied !== commonGitDir) throw new ScanError("git_dir_repo_mismatch");
|
|
106
|
+
}
|
|
107
|
+
// Canonical refs avoid Git's ambiguous short-name resolution; this check adds no traversal roots.
|
|
108
|
+
if (options.devRef !== undefined) {
|
|
109
|
+
const ref = options.devRef === "dev" ? "refs/heads/dev" : options.devRef === "origin/dev" ? "refs/remotes/origin/dev" : options.devRef;
|
|
110
|
+
git([...selected, "show-ref", "--verify", "--", ref]);
|
|
111
|
+
}
|
|
112
|
+
const head = options.scope === "tracked" || options.syncHashes ? null : git([...selected, "rev-parse", "--verify", "HEAD"]);
|
|
113
|
+
if (options.out !== undefined) {
|
|
114
|
+
try { lstatSync(options.out); throw new ScanError("out_exists"); }
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
117
|
+
}
|
|
118
|
+
const output = join(realpathSync(dirname(options.out)), basename(options.out));
|
|
119
|
+
const protectedFiles = [...protectedPaths, ...(options.baseline === undefined ? [] : [options.baseline]),
|
|
120
|
+
join(root, "scripts/private-strings.hashes.json")];
|
|
121
|
+
for (const file of protectedFiles) {
|
|
122
|
+
let canonical = resolve(file);
|
|
123
|
+
try { canonical = join(realpathSync(dirname(file)), basename(file)); }
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
126
|
+
}
|
|
127
|
+
if (output === canonical) throw new ScanError("out_protected_file");
|
|
128
|
+
}
|
|
129
|
+
if ([gitDir, commonGitDir, join(root, ".git")].some((dir) => output === dir || output.startsWith(`${dir}${sep}`))) {
|
|
130
|
+
throw new ScanError("out_git_data");
|
|
131
|
+
}
|
|
132
|
+
accessSync(dirname(output), constants.W_OK);
|
|
133
|
+
}
|
|
134
|
+
const exceptions = options.exceptions === undefined ? new Set<string>() : parseObjectExceptions(readFileSync(options.exceptions, "utf8"));
|
|
135
|
+
return { root, gitDir, commonGitDir, head, bare, exceptions };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type ScanFreshness = "verified" | "unchecked_offline" | "stale" | "mismatch" | "probe_failed" | "legacy_unverifiable";
|
|
139
|
+
export interface ScanBaseline { readonly source: "baseline" | "legacy-list"; readonly revision: number | null }
|
|
140
|
+
export interface ResolvedScanBaseline extends ScanBaseline {
|
|
141
|
+
/** Keep the validated snapshot for revision checks and execution; never reopen the source. */
|
|
142
|
+
readonly file: BaselineFile | null;
|
|
143
|
+
readonly terms: readonly string[];
|
|
144
|
+
readonly home: string;
|
|
145
|
+
readonly user: string;
|
|
146
|
+
}
|
|
147
|
+
export interface ScanBaselineDeps {
|
|
148
|
+
readonly env?: Env;
|
|
149
|
+
readonly machine?: { readonly home: string; readonly user: string };
|
|
150
|
+
readonly lang?: Language;
|
|
151
|
+
readonly err: (text: string) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Protect every candidate, including absent defaults, before selecting or reading a baseline. */
|
|
155
|
+
export function scanBaselinePaths(options: ScanOptions, env: Env = process.env): readonly string[] {
|
|
156
|
+
const defaults = securityBaselinePaths({ ...env, ZGW_SECURITY_BASELINE_FILE: undefined, ZIGMINI_PRIVATE_STRINGS: undefined }, runtimePaths(env));
|
|
157
|
+
return [defaults.securityBaselineFile, defaults.legacyPrivateStringsFile,
|
|
158
|
+
options.baseline, env.ZGW_SECURITY_BASELINE_FILE, env.ZIGMINI_PRIVATE_STRINGS]
|
|
159
|
+
.filter((path): path is string => path !== undefined && path.trim().length > 0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Select once, validate that source, then append current-machine terms only after nonempty validation. */
|
|
163
|
+
export function resolveScanBaseline(options: ScanOptions, deps: ScanBaselineDeps): ResolvedScanBaseline {
|
|
164
|
+
const env = deps.env ?? process.env;
|
|
165
|
+
for (const value of [env.ZGW_SECURITY_BASELINE_FILE, env.ZIGMINI_PRIVATE_STRINGS]) {
|
|
166
|
+
if (value !== undefined && value.trim().length === 0) throw new ScanError("baseline_env_empty");
|
|
167
|
+
}
|
|
168
|
+
const source = resolveBaselineSource({ baselineFlag: options.baseline, env });
|
|
169
|
+
let file: BaselineFile | null = null;
|
|
170
|
+
let terms: readonly string[];
|
|
171
|
+
switch (source.kind) {
|
|
172
|
+
case "baseline": {
|
|
173
|
+
const result = readBaseline(source);
|
|
174
|
+
if (!result.ok) throw new ScanError(`baseline_${result.error}`, result.error === "missing" || result.error === "empty" ? 3 : 2);
|
|
175
|
+
file = result.file;
|
|
176
|
+
terms = file.terms;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "legacy-list":
|
|
180
|
+
deps.err(`${TEXT[deps.lang ?? "en"].legacy}\n`);
|
|
181
|
+
try { terms = parseList(readFileSync(source.path, "utf8")); }
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
184
|
+
throw new ScanError(error.code === "ENOENT" ? "baseline_missing" : "baseline_unreadable", error.code === "ENOENT" ? 3 : 2);
|
|
185
|
+
}
|
|
186
|
+
if (terms.length === 0) throw new ScanError("baseline_empty", 3);
|
|
187
|
+
break;
|
|
188
|
+
case "missing": throw new ScanError("baseline_missing", 3);
|
|
189
|
+
case "hash-only": throw new ScanError("baseline_source_invalid");
|
|
190
|
+
default: {
|
|
191
|
+
const unreachable: never = source;
|
|
192
|
+
throw new ScanError(`baseline_source_invalid:${unreachable}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const machine = deps.machine ?? { home: homedir(), user: userInfo().username };
|
|
196
|
+
const automatic = machineTerms(machine);
|
|
197
|
+
return { source: source.kind, revision: file?.revision ?? null, file,
|
|
198
|
+
terms: [...terms, ...automatic.terms.filter((term) => !terms.includes(term))], home: automatic.home, user: machine.user };
|
|
199
|
+
}
|
|
200
|
+
export interface ScanResult {
|
|
201
|
+
readonly counts: { readonly total: number };
|
|
202
|
+
readonly findings?: readonly PrivateFinding[];
|
|
203
|
+
readonly allowedRepo?: number;
|
|
204
|
+
readonly hashes?: number | "absent";
|
|
205
|
+
/** Internal snapshot only. Never serialize a result or a finding directly. */
|
|
206
|
+
readonly redaction?: { readonly home: string; readonly set: PrivateSet };
|
|
207
|
+
readonly sync?: HashSyncCounts;
|
|
208
|
+
}
|
|
209
|
+
export interface HashSyncCounts {
|
|
210
|
+
readonly inputTerms: number;
|
|
211
|
+
readonly uniqueTerms: number;
|
|
212
|
+
readonly duplicates: number;
|
|
213
|
+
readonly excluded: Readonly<Record<HashSyncExclusion, number>>;
|
|
214
|
+
readonly exportedHashes: number;
|
|
215
|
+
readonly changed: boolean;
|
|
216
|
+
}
|
|
217
|
+
export type HashSyncExclusion = "tooShort" | "asciiShort" | "automatic" | "loopback" | "publicGithub";
|
|
218
|
+
export interface HashSyncResult extends ScanResult { readonly sync: HashSyncCounts }
|
|
219
|
+
|
|
220
|
+
/** D8 reasons are exclusive; automatic/public values take precedence over length. */
|
|
221
|
+
export function hashSyncExclusion(term: string, baseline: ResolvedScanBaseline): HashSyncExclusion | null {
|
|
222
|
+
const value = normalizeTerm(term);
|
|
223
|
+
if ([baseline.home, baseline.user, baseline.file?.home, baseline.file?.user]
|
|
224
|
+
.some((automatic) => automatic !== undefined && normalizeTerm(automatic) === value)) return "automatic";
|
|
225
|
+
if (/^(?:(?:wss?|https?):\/\/)?(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\]|::1)(?::\d+)?\/?$/.test(value)) return "loopback";
|
|
226
|
+
if (value.replace(PUBLIC_ACCOUNT, "") !== value) return "publicGithub";
|
|
227
|
+
if ([...value].length < 8) return "tooShort";
|
|
228
|
+
if (/^[a-z]+$/.test(value) && value.length < 10) return "asciiShort";
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Synchronize only the validated in-memory baseline; never scan, stage or commit. */
|
|
233
|
+
export async function syncHashes(options: ScanOptions, target: ScanTarget, baseline: ResolvedScanBaseline): Promise<HashSyncResult> {
|
|
234
|
+
const scripts = join(target.root, "scripts"), file = join(scripts, "private-strings.hashes.json");
|
|
235
|
+
try {
|
|
236
|
+
const parent = lstatSync(scripts);
|
|
237
|
+
if (!parent.isDirectory()) throw new ScanError("hash_sync_scripts_invalid");
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
240
|
+
mkdirSync(scripts);
|
|
241
|
+
}
|
|
242
|
+
let previous: string | null = null;
|
|
243
|
+
try {
|
|
244
|
+
const entry = lstatSync(file);
|
|
245
|
+
if (!entry.isFile()) throw new ScanError("hash_sync_file_invalid");
|
|
246
|
+
previous = readFileSync(file, "utf8");
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
249
|
+
}
|
|
250
|
+
// Reuse the scanner's salt/hash validation, even for --prune. Invalid files fail closed.
|
|
251
|
+
const existing = loadPrivateSet(hashOnlySource(), file, { home: baseline.home, user: baseline.user }).hashes;
|
|
252
|
+
const terms = new Set(baseline.terms.map(normalizeTerm));
|
|
253
|
+
const excluded = { tooShort: 0, asciiShort: 0, automatic: 0, loopback: 0, publicGithub: 0 };
|
|
254
|
+
const hashes = new Set(options.prune ? [] : existing);
|
|
255
|
+
for (const term of terms) {
|
|
256
|
+
const reason = hashSyncExclusion(term, baseline);
|
|
257
|
+
if (reason === null) hashes.add(hashTerm(term));
|
|
258
|
+
else excluded[reason]++;
|
|
259
|
+
}
|
|
260
|
+
const text = `${JSON.stringify({ salt: HASH_SALT, hashes: [...hashes].sort() }, null, 2)}\n`;
|
|
261
|
+
const changed = text !== previous;
|
|
262
|
+
if (changed) {
|
|
263
|
+
const temporary = join(scripts, `.private-strings.hashes-${randomUUID()}.tmp`);
|
|
264
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
265
|
+
let renamed = false;
|
|
266
|
+
try {
|
|
267
|
+
try { writeFileSync(fd, text); fsyncSync(fd); }
|
|
268
|
+
finally { closeSync(fd); }
|
|
269
|
+
// A newly introduced symlink must not become the destination of the replacement.
|
|
270
|
+
try {
|
|
271
|
+
const current = lstatSync(file);
|
|
272
|
+
if (!current.isFile()) throw new ScanError("hash_sync_file_invalid");
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
275
|
+
}
|
|
276
|
+
renameSync(temporary, file);
|
|
277
|
+
renamed = true;
|
|
278
|
+
} finally { if (!renamed) unlinkSync(temporary); }
|
|
279
|
+
}
|
|
280
|
+
return { counts: { total: 0 }, hashes: hashes.size, sync: {
|
|
281
|
+
inputTerms: baseline.terms.length, uniqueTerms: terms.size, duplicates: baseline.terms.length - terms.size,
|
|
282
|
+
excluded, exportedHashes: hashes.size, changed,
|
|
283
|
+
} };
|
|
284
|
+
}
|
|
285
|
+
export interface RepositoryScanResult extends ScanResult {
|
|
286
|
+
readonly findings: readonly PrivateFinding[];
|
|
287
|
+
readonly allowedRepo: number;
|
|
288
|
+
readonly hashes: number | "absent";
|
|
289
|
+
readonly redaction: { readonly home: string; readonly set: PrivateSet };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Scan the selected snapshot, then apply repository permissions before counting or reporting. */
|
|
293
|
+
export async function scanRepository(options: ScanOptions, target: ScanTarget, baseline: ResolvedScanBaseline): Promise<RepositoryScanResult> {
|
|
294
|
+
const machine = { home: baseline.home, user: baseline.user };
|
|
295
|
+
const loaded = loadPrivateSet(hashOnlySource(), join(target.root, "scripts/private-strings.hashes.json"), machine);
|
|
296
|
+
const set = { hashes: loaded.hashes, terms: baseline.terms };
|
|
297
|
+
const findings = [
|
|
298
|
+
...(options.scope === "git" ? [] : scanTracked(target.root, set)),
|
|
299
|
+
...(options.scope === "tracked" ? [] : scanGitObjects(target.gitDir, set, target.exceptions)),
|
|
300
|
+
];
|
|
301
|
+
const { kept, allowedRepo } = applyRepoAllow(findings, baseline.file?.allow ?? [], machine);
|
|
302
|
+
return { findings: kept, counts: { total: kept.length }, allowedRepo, hashes: loaded.hashCount,
|
|
303
|
+
redaction: { home: baseline.home, set: { hashes: set.hashes,
|
|
304
|
+
terms: [...baseline.terms, ...(baseline.file?.allow.map(({ value }) => value) ?? [])] } } };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface ScanReport {
|
|
308
|
+
readonly status: "complete" | "incomplete" | "not_scanned";
|
|
309
|
+
readonly didScan: boolean;
|
|
310
|
+
readonly source: ScanBaseline["source"] | null;
|
|
311
|
+
readonly revision: number | null;
|
|
312
|
+
readonly freshness: ScanFreshness | null;
|
|
313
|
+
readonly counts: ScanResult["counts"] | null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Remove complete values before escaping terminal controls; never leave a value prefix. */
|
|
317
|
+
export function redactScanLocation(file: string, redaction: NonNullable<ScanResult["redaction"]>): string {
|
|
318
|
+
const path = tildeHome(file, redaction.home).normalize("NFC");
|
|
319
|
+
const values = [...redaction.set.terms, ...findPrivate(path, redaction.set).map((hit) => hit.value)]
|
|
320
|
+
.filter((value) => value.length > 0).map((value) => value.normalize("NFC"));
|
|
321
|
+
const pattern = [...new Set(values)].sort((a, b) => b.length - a.length)
|
|
322
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
323
|
+
const safe = pattern.length === 0 ? path : path.replace(new RegExp(pattern, "gi"), "<redacted>");
|
|
324
|
+
return safe.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g,
|
|
325
|
+
(value) => `\\u${value.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Project trusted metadata and safe locations, never values, tokens or baseline contents. */
|
|
329
|
+
export function renderScanReport(report: ScanReport, options: ScanOptions, result: ScanResult | null): string {
|
|
330
|
+
const findings = result?.findings ?? [];
|
|
331
|
+
const byRule: Record<string, number> = {};
|
|
332
|
+
for (const finding of findings) byRule[finding.rule] = (byRule[finding.rule] ?? 0) + 1;
|
|
333
|
+
const summary = { type: "summary", ...report,
|
|
334
|
+
counts: report.counts === null ? null : { ...report.counts, byRule },
|
|
335
|
+
allowedRepo: result?.allowedRepo ?? null, hashes: result?.hashes ?? null,
|
|
336
|
+
...(result?.sync === undefined ? {} : { sync: result.sync }) };
|
|
337
|
+
const locations = options.quiet ? [] : findings.slice(0, options.json ? findings.length : 50).map((finding) => {
|
|
338
|
+
if (result?.redaction === undefined) throw new ScanError("report_redaction_missing");
|
|
339
|
+
return { type: "finding", rule: finding.rule, file: redactScanLocation(finding.file, result.redaction), line: finding.line ?? null };
|
|
340
|
+
});
|
|
341
|
+
if (options.json) return `${[...locations, summary].map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
342
|
+
const lines = [`SECURITY_FINDING_COUNTS ${JSON.stringify(summary.counts)}`,
|
|
343
|
+
...locations.map((location) => `${location.rule} ${location.file}:${location.line ?? "-"}`)];
|
|
344
|
+
if (result?.sync !== undefined) lines.push("Hash synchronization only; the repository was not scanned.");
|
|
345
|
+
if (!options.quiet && findings.length > 50) lines.push(`... ${findings.length} findings`);
|
|
346
|
+
lines.push(`SECURITY_SCAN_SUMMARY ${JSON.stringify(summary)}`);
|
|
347
|
+
return `${lines.join("\n")}\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Exclusive creation also rejects symlinks and races after prepareScan's preflight. */
|
|
351
|
+
export function reportScan(report: ScanReport, options: ScanOptions, result: ScanResult | null,
|
|
352
|
+
out: (text: string) => void = (text) => { process.stdout.write(text); }): void {
|
|
353
|
+
const text = renderScanReport(report, options, result);
|
|
354
|
+
if (options.out === undefined) out(text);
|
|
355
|
+
else writeFileSync(options.out, text, { flag: "wx", mode: 0o600 });
|
|
356
|
+
}
|
|
357
|
+
/** Later todos supply real baseline/probe/scan/sync/report adapters; no default adapter silently succeeds. */
|
|
358
|
+
export interface ScanDeps<B extends ScanBaseline = ScanBaseline, R extends ScanResult = ScanResult> {
|
|
359
|
+
readonly lang: Language;
|
|
360
|
+
readonly out: (text: string) => void;
|
|
361
|
+
readonly err: (text: string) => void;
|
|
362
|
+
readonly prepare: (options: ScanOptions) => ScanTarget | Promise<ScanTarget>;
|
|
363
|
+
readonly baseline: (options: ScanOptions, target: ScanTarget) => B | Promise<B>;
|
|
364
|
+
readonly revision: (baseline: B) => Promise<ScanFreshness>;
|
|
365
|
+
readonly scan: (options: ScanOptions, target: ScanTarget, baseline: B) => Promise<R>;
|
|
366
|
+
readonly syncHashes: (options: ScanOptions, target: ScanTarget, baseline: B) => Promise<R>;
|
|
367
|
+
readonly report: (report: ScanReport, options: ScanOptions, result: R | null) => void | Promise<void>;
|
|
368
|
+
}
|
|
369
|
+
export function scanExitCode(total: number): 0 | 1 {
|
|
370
|
+
return total > 0 ? 1 : 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The orchestration boundary owns precedence: usage/input, baseline, freshness, execution, output. */
|
|
374
|
+
export async function runScan<B extends ScanBaseline, R extends ScanResult>(options: ScanOptions | string, deps: ScanDeps<B, R>): Promise<ScanExitCode> {
|
|
375
|
+
try {
|
|
376
|
+
if (typeof options === "string") { deps.err(`${options}\n${scanHelp(deps.lang)}`); return 2; }
|
|
377
|
+
if (options.help) { deps.out(scanHelp(deps.lang)); return 0; }
|
|
378
|
+
let phase: "input" | "baseline" | "revision" | "execution" = "input";
|
|
379
|
+
let report: ScanReport = { status: "not_scanned", didScan: false, source: null, revision: null, freshness: null, counts: null };
|
|
380
|
+
let result: R | null = null;
|
|
381
|
+
let code: ScanExitCode;
|
|
382
|
+
try {
|
|
383
|
+
const target = await deps.prepare(options);
|
|
384
|
+
phase = "baseline";
|
|
385
|
+
const baseline = await deps.baseline(options, target);
|
|
386
|
+
report = { ...report, source: baseline.source, revision: baseline.revision };
|
|
387
|
+
phase = "revision";
|
|
388
|
+
const freshness = await deps.revision(baseline);
|
|
389
|
+
report = { ...report, freshness };
|
|
390
|
+
if (freshness !== "verified" && freshness !== "unchecked_offline") throw new ScanError(`baseline_${freshness}`, 3);
|
|
391
|
+
phase = "execution";
|
|
392
|
+
report = { ...report, status: "incomplete", didScan: !options.syncHashes };
|
|
393
|
+
result = options.syncHashes ? await deps.syncHashes(options, target, baseline) : await deps.scan(options, target, baseline);
|
|
394
|
+
report = { ...report, status: "complete", counts: result.counts };
|
|
395
|
+
code = options.syncHashes ? 0 : scanExitCode(result.counts.total);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
code = phase === "baseline" || phase === "revision" ? (error instanceof ScanError ? error.exitCode : 2) : 2;
|
|
398
|
+
const reason = error instanceof ScanError ? error.code : `scan_${phase}_failed`;
|
|
399
|
+
deps.err(`${TEXT[deps.lang].error}: ${reason}\n`);
|
|
400
|
+
}
|
|
401
|
+
// Failed input validation must not send a report to an unvalidated or forbidden output path.
|
|
402
|
+
const reportOptions = phase === "input" ? { ...options, out: undefined } : options;
|
|
403
|
+
try { await deps.report(report, reportOptions, result); }
|
|
404
|
+
catch (error) {
|
|
405
|
+
// I/O error messages can contain private paths or values.
|
|
406
|
+
deps.err(`${TEXT[deps.lang].output}: ${error instanceof ScanError ? error.code : "scan_output_failed"}\n`);
|
|
407
|
+
return 2;
|
|
408
|
+
}
|
|
409
|
+
return code;
|
|
410
|
+
} catch {
|
|
411
|
+
// The final CLI boundary cannot report a broken stdout/stderr through that same stream.
|
|
412
|
+
return 2;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { runtimePaths, securityBaselinePaths, type Env } from "../runtime/paths.ts";
|
|
6
|
+
import { parseList } from "../security/repo-scan.ts";
|
|
7
|
+
import { findPrivate, normalizeTerm, PUBLIC_ACCOUNT, tildeHome } from "../security/terms.ts";
|
|
8
|
+
import type { FetchLike } from "./pair.ts";
|
|
9
|
+
|
|
10
|
+
export interface ImportOptions {
|
|
11
|
+
readonly from?: string;
|
|
12
|
+
readonly dryRun: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ImportMarker {
|
|
16
|
+
readonly importedAt: string;
|
|
17
|
+
readonly count: number;
|
|
18
|
+
readonly source: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ImportDeps {
|
|
22
|
+
readonly env: Env;
|
|
23
|
+
readonly fetch: FetchLike;
|
|
24
|
+
readonly readFile: (path: string) => Promise<string>;
|
|
25
|
+
readonly writeMarker: (path: string, marker: ImportMarker) => void;
|
|
26
|
+
readonly now: () => Date;
|
|
27
|
+
readonly out: (text: string) => void;
|
|
28
|
+
readonly err: (text: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const USAGE = "zigmini security import-baseline [--from <file>] [--dry-run]";
|
|
32
|
+
|
|
33
|
+
export function parseImportArgs(argv: readonly string[]): ImportOptions | string {
|
|
34
|
+
let from: string | undefined;
|
|
35
|
+
let dryRun = false;
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
switch (argv[i]) {
|
|
38
|
+
case "--from": {
|
|
39
|
+
const value = argv[++i];
|
|
40
|
+
if (from !== undefined || !value || value.startsWith("-") || /[\r\n\0]/.test(value)) return USAGE;
|
|
41
|
+
from = value;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case "--dry-run":
|
|
45
|
+
if (dryRun) return USAGE;
|
|
46
|
+
dryRun = true;
|
|
47
|
+
break;
|
|
48
|
+
default: return USAGE;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ...(from === undefined ? {} : { from }), dryRun };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class ImportError extends Error {
|
|
55
|
+
constructor(readonly code: "gateway_down" | "gateway_response" | "revision_conflict" | "list_unreadable" | "receipt_failed") {
|
|
56
|
+
super(code);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
61
|
+
return typeof value === "object" && value !== null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function revision(value: unknown): value is number {
|
|
65
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The list is the only input file; every gateway change crosses its revision-checked HTTP boundary. */
|
|
69
|
+
export async function runImport(options: ImportOptions, deps: ImportDeps): Promise<number> {
|
|
70
|
+
const counts = { read: 0, added: 0, duplicates: 0, rejected: 0 };
|
|
71
|
+
const request = async (method: "GET" | "POST", body?: unknown) => {
|
|
72
|
+
let response: Awaited<ReturnType<FetchLike>>;
|
|
73
|
+
try {
|
|
74
|
+
response = await deps.fetch(`http://127.0.0.1:${runtimePaths(deps.env).ports.web}/api/settings/security-check/baseline`, {
|
|
75
|
+
method, redirect: "error", ...(body === undefined ? {} : {
|
|
76
|
+
headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
} catch { throw new ImportError("gateway_down"); }
|
|
80
|
+
let data: unknown;
|
|
81
|
+
try { data = await response.json(); }
|
|
82
|
+
catch { throw new ImportError("gateway_response"); }
|
|
83
|
+
if (!object(data)) throw new ImportError("gateway_response");
|
|
84
|
+
return { status: response.status, data };
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
const home = deps.env.HOME || homedir();
|
|
88
|
+
const selected = options.from ?? securityBaselinePaths(deps.env).legacyPrivateStringsFile;
|
|
89
|
+
const source = resolve(selected.startsWith("~/") ? join(home, selected.slice(2)) : selected);
|
|
90
|
+
let text: string;
|
|
91
|
+
try { text = await deps.readFile(source); }
|
|
92
|
+
catch { throw new ImportError("list_unreadable"); }
|
|
93
|
+
const values = parseList(text).map(normalizeTerm);
|
|
94
|
+
counts.read = values.length;
|
|
95
|
+
const initial = await request("GET");
|
|
96
|
+
if (initial.status !== 200 || !revision(initial.data.revision) || !Array.isArray(initial.data.terms)) {
|
|
97
|
+
throw new ImportError("gateway_response");
|
|
98
|
+
}
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
for (const term of initial.data.terms) {
|
|
101
|
+
if (!object(term) || typeof term.value !== "string") throw new ImportError("gateway_response");
|
|
102
|
+
seen.add(term.value);
|
|
103
|
+
}
|
|
104
|
+
let expectedRevision = initial.data.revision;
|
|
105
|
+
for (const value of values) {
|
|
106
|
+
// Reuse the list predicate, including its public-account masking exception, without loading gateway state.
|
|
107
|
+
if (value.length < 3 || (!value.match(PUBLIC_ACCOUNT) &&
|
|
108
|
+
!findPrivate(value, { terms: [value], hashes: new Set() }).some((hit) => hit.rule === "private-list"))) {
|
|
109
|
+
counts.rejected++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(value)) { counts.duplicates++; continue; }
|
|
113
|
+
seen.add(value);
|
|
114
|
+
if (options.dryRun) { counts.added++; continue; }
|
|
115
|
+
const response = await request("POST", { value, expectedRevision });
|
|
116
|
+
if (response.status === 400 && response.data.error === "term_duplicate") { counts.duplicates++; continue; }
|
|
117
|
+
if (response.status === 400 && response.data.error === "term_invalid") { counts.rejected++; continue; }
|
|
118
|
+
if (response.status === 409) throw new ImportError("revision_conflict");
|
|
119
|
+
if (response.status !== 200 || !revision(response.data.revision) || response.data.revision !== expectedRevision + 1) {
|
|
120
|
+
throw new ImportError("gateway_response");
|
|
121
|
+
}
|
|
122
|
+
expectedRevision = response.data.revision;
|
|
123
|
+
counts.added++;
|
|
124
|
+
}
|
|
125
|
+
if (!options.dryRun) {
|
|
126
|
+
try {
|
|
127
|
+
deps.writeMarker(join(runtimePaths(deps.env).secrets, "baseline-imported"), {
|
|
128
|
+
importedAt: deps.now().toISOString(), count: counts.added, source: tildeHome(source, home),
|
|
129
|
+
});
|
|
130
|
+
} catch { throw new ImportError("receipt_failed"); }
|
|
131
|
+
}
|
|
132
|
+
deps.out(`${JSON.stringify(counts)}\n`);
|
|
133
|
+
return 0;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// The CLI boundary never renders file contents, paths, response bodies or adapter error messages.
|
|
136
|
+
const code = error instanceof ImportError ? error.code : "gateway_response";
|
|
137
|
+
deps.out(`${JSON.stringify(counts)}\n`);
|
|
138
|
+
deps.err(code === "gateway_down" ? "게이트웨이가 켜져 있어야 가져올 수 있습니다.\n" : `security_import_${code}\n`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function systemImportDeps(env: Env = process.env): ImportDeps {
|
|
144
|
+
return {
|
|
145
|
+
env, fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(10_000) }),
|
|
146
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
147
|
+
writeMarker: (path, marker) => {
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
149
|
+
chmodSync(dirname(path), 0o700);
|
|
150
|
+
writeFileSync(path, JSON.stringify(marker) + "\n", { mode: 0o600 });
|
|
151
|
+
chmodSync(path, 0o600);
|
|
152
|
+
},
|
|
153
|
+
now: () => new Date(), out: (text) => process.stdout.write(text), err: (text) => process.stderr.write(text),
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/compact.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/compact` (improvement 230): the gateway asks the engine to summarize the conversation's session now (RPC `compact`)
|
|
3
|
+
* and never sends the text to the model as a prompt.
|
|
4
|
+
*/
|
|
5
|
+
const COMPACT_DESCRIPTION: Text = {
|
|
6
|
+
en: "Summarize the conversation, e.g. /compact focus on decisions (sent directly to the engine by the gateway)",
|
|
7
|
+
ko: "대화 요약(압축)하기, 예: /compact 결정 사항 위주로 (게이트웨이가 엔진에 바로 요청함)",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const COMPACT_COMMAND = {
|
|
11
|
+
name: "compact",
|
|
12
|
+
get description() { return say(COMPACT_DESCRIPTION); },
|
|
13
|
+
source: "extension",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The extra summary instructions of a `/compact` message ("" for none), or null when the text is not that command. */
|
|
17
|
+
export function compactInstructions(text: string): string | null {
|
|
18
|
+
const match = /^\/compact(?:\s+([\s\S]*))?$/i.exec(text.trim());
|
|
19
|
+
return match ? (match[1] ?? "").trim() : null;
|
|
20
|
+
}
|
|
21
|
+
import { say, type Text } from "./server-text.ts";
|
|
22
|
+
|