@itenl/sec 0.0.0-stage → 0.1.0

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/bin/sec.mjs ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../lib/cli.mjs';
3
+
4
+ main()
5
+ .then((code) => {
6
+ process.exitCode = code;
7
+ })
8
+ .catch((error) => {
9
+ // External command errors can contain captured credentials or source text.
10
+ const message =
11
+ error.code || error instanceof SyntaxError || error.message?.startsWith('Command failed')
12
+ ? 'Operation failed. Run sec doctor and inspect private diagnostics.'
13
+ : error.message;
14
+ console.error(`sec: ${message}`);
15
+ process.exitCode = 2;
16
+ });
@@ -0,0 +1,46 @@
1
+ import { copyFileSync, constants, existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { commands } from './config.mjs';
6
+
7
+ const start = '# >>> sec CLI aliases >>>';
8
+ const end = '# <<< sec CLI aliases <<<';
9
+ export function aliasBlock() {
10
+ return `${start}\n${commands.map((name) => `alias 'sec:${name}'='sec ${name}'`).join('\n')}\n${end}\n`;
11
+ }
12
+ export function updateAliases(previous) {
13
+ const from = previous.indexOf(start),
14
+ to = previous.indexOf(end);
15
+ if (
16
+ from < 0 !== to < 0 ||
17
+ (from >= 0 &&
18
+ (to < from ||
19
+ previous.indexOf(start, from + start.length) >= 0 ||
20
+ previous.indexOf(end, to + end.length) >= 0))
21
+ ) {
22
+ throw new Error('Incomplete or duplicate sec alias markers; shell file was not changed.');
23
+ }
24
+ if (from < 0) return `${previous}${previous && !previous.endsWith('\n') ? '\n' : ''}${aliasBlock()}`;
25
+ return previous.slice(0, from) + aliasBlock().trimEnd() + previous.slice(to + end.length);
26
+ }
27
+ export function installAliases({
28
+ env = process.env,
29
+ shell = basename(env.SHELL || ''),
30
+ log = console.log
31
+ } = {}) {
32
+ if (!['zsh', 'bash'].includes(shell))
33
+ throw new Error('Colon aliases support zsh and bash. Use sec branch in other shells.');
34
+ const home = env.HOME || homedir();
35
+ const path = shell === 'zsh' ? join(env.ZDOTDIR || home, '.zshrc') : join(home, '.bashrc');
36
+ if (existsSync(path) && (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile()))
37
+ throw new Error('Shell configuration is not a regular file; install aliases manually.');
38
+ const previous = existsSync(path) ? readFileSync(path, 'utf8') : '';
39
+ const next = updateAliases(previous);
40
+ if (next !== previous) {
41
+ if (existsSync(path)) copyFileSync(path, `${path}.sec-backup-${randomUUID()}`, constants.COPYFILE_EXCL);
42
+ writeFileSync(path, next, { mode: 0o600 });
43
+ }
44
+ log(`快捷命令已配置:${path}。打开新终端,或在当前终端 source 该文件。`);
45
+ return path;
46
+ }
package/lib/auth.mjs ADDED
@@ -0,0 +1,94 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { readConfig } from './config.mjs';
5
+ import { engineCommand, engineEnvironment, ensureEngine } from './engine.mjs';
6
+
7
+ // Only classify known status messages. Never return raw subprocess output: even
8
+ // successful status output may contain a partially masked API key or account data.
9
+ export function savedLoginStatus(result) {
10
+ if (result.error || result.signal) return 'unknown';
11
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
12
+ if (result.status === 0) {
13
+ const apiKey = /^(?:Logged in using an API key|API key configured)(?:\s|$)/im.test(output);
14
+ const chatgpt = /^Logged in using ChatGPT(?:\s|$)/im.test(output);
15
+ if (/^Not logged in\s*$/im.test(output)) return 'unknown';
16
+ if (apiKey && !chatgpt) return 'api_key';
17
+ if (chatgpt && !apiKey) return 'chatgpt';
18
+ }
19
+ if (result.status === 1 && /^Not logged in\s*$/im.test(output)) return 'none';
20
+ return 'unknown';
21
+ }
22
+
23
+ export function authenticationStatus({ env = process.env, run = spawnSync, check = ensureEngine } = {}) {
24
+ const config = readConfig(env);
25
+ const childEnv = engineEnvironment(env);
26
+ const environmentApiKeys = ['OPENAI_API_KEY', 'CODEX_API_KEY'].filter((name) => env[name]?.trim());
27
+ // Ask specifically about the saved login. Environment key precedence is
28
+ // calculated separately using the engine's documented noninteractive order.
29
+ delete childEnv.OPENAI_API_KEY;
30
+ delete childEnv.CODEX_API_KEY;
31
+ let savedLogin = 'unknown';
32
+ let diagnostic = null;
33
+ try {
34
+ check(childEnv);
35
+ const result = run(engineCommand(childEnv), ['login', 'status'], {
36
+ env: childEnv,
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ timeout: 10000,
40
+ killSignal: 'SIGKILL',
41
+ maxBuffer: 64 * 1024
42
+ });
43
+ savedLogin = savedLoginStatus(result);
44
+ if (savedLogin === 'unknown') diagnostic = 'status_unavailable';
45
+ } catch {
46
+ // Errors can contain credentials. Return a fixed diagnostic, never error.message.
47
+ diagnostic = 'engine_unavailable';
48
+ }
49
+ const source =
50
+ environmentApiKeys[0] || (['api_key', 'chatgpt'].includes(savedLogin) ? 'saved_login' : null);
51
+ return {
52
+ savedLogin,
53
+ environmentApiKeys,
54
+ noninteractiveDefault: {
55
+ method: environmentApiKeys.length ? 'api_key' : savedLogin,
56
+ source
57
+ },
58
+ credentialHome: join(childEnv.CODEX_SECURITY_STATE_DIR, 'codex-home'),
59
+ provider: config.baseUrl ? 'custom' : 'openai',
60
+ diagnostic
61
+ };
62
+ }
63
+
64
+ export function main(args = [], dependencies = {}) {
65
+ const { values } = parseArgs({ args, options: { json: { type: 'boolean' } }, allowPositionals: false });
66
+ const status = authenticationStatus(dependencies);
67
+ const log = dependencies.log || console.log;
68
+ if (values.json) log(JSON.stringify(status, null, 2));
69
+ else {
70
+ const labels = {
71
+ api_key: 'API key',
72
+ chatgpt: 'ChatGPT 账号登录(可能使用账号的 Codex 权益)',
73
+ none: '未登录',
74
+ unknown: '无法确认'
75
+ };
76
+ log(`已保存的 sec 登录:${labels[status.savedLogin]}`);
77
+ log(`环境 API key:${status.environmentApiKeys.join('、') || '未设置'}(不显示值)`);
78
+ log(`非交互扫描默认认证:${labels[status.noninteractiveDefault.method]}`);
79
+ log(`凭据来源:${status.noninteractiveDefault.source || '无'}`);
80
+ log(`sec 独立认证目录:${status.credentialHome}`);
81
+ log(`服务:${status.provider === 'custom' ? '自定义 provider' : 'OpenAI'}`);
82
+ if (status.environmentApiKeys.length)
83
+ log('环境 API key 优先于保存的登录;交互式扫描若引擎提示选择,以当次选择为准。');
84
+ if (status.provider === 'custom' && status.savedLogin === 'chatgpt')
85
+ log('自定义 provider 不保证接受 ChatGPT 账号登录;需使用该服务支持的凭据。');
86
+ if (status.diagnostic)
87
+ log(
88
+ '未能确认保存的登录状态。检查 sec doctor;登录状态查询与引擎检查各有 10 秒上限,不自动登录或覆盖凭据。'
89
+ );
90
+ log('仅查询认证来源,不请求模型;不验证 key 有效性、账号订阅权益、剩余额度或最终计费。');
91
+ log('官方状态查询可能初始化或同步 sec 认证缓存;不修改业务仓库或日常 Codex 登录。');
92
+ }
93
+ return status.savedLogin === 'unknown' || status.noninteractiveDefault.method === 'none' ? 2 : 0;
94
+ }
package/lib/cli.mjs ADDED
@@ -0,0 +1,169 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { readProjectConfig, projectConfigFile, findingId } from './project.mjs';
5
+ import {
6
+ configFile,
7
+ repositoryConfigFile,
8
+ repositoryDirectory,
9
+ trackerFile,
10
+ userDirectories,
11
+ commands
12
+ } from './config.mjs';
13
+ import { readTracker, renderFindings } from './tracker.mjs';
14
+ import { aliasBlock, installAliases } from './aliases.mjs';
15
+
16
+ export function help() {
17
+ console.log(`sec — 全局安全扫描工作流
18
+
19
+ 首次使用:sec setup(用户级配置、隐藏输入 API key,可安装 sec:xxx 快捷命令)
20
+ 项目接入:sec init project [--dry-run] 创建通用规范、模板、共享台账、sec.config.json
21
+ sec scan [--knowledge-base <path>] 全仓当前工作区
22
+ sec diff [--knowledge-base <path>] HEAD 到 index 的差异,保留部分暂存
23
+ sec branch [--base <ref>] [--dry-run] [--knowledge-base <path>]
24
+ 目标:--base → 本机仓库配置 → 项目共享配置 → 用户配置 → origin/HEAD
25
+ 仅扫描共同祖先到 HEAD 的已提交差异
26
+ sec list [--severity <level>] [--all] 读取当前有效台账,无模型请求
27
+ sec update SEC-001 [SEC-002 ...] 将指定编号追加到待验证队列,编号前缀可由项目配置
28
+ sec verify [--model <model>] [--effort <effort>]
29
+ 仅复核队列,静态检查,不执行线上验证
30
+ sec paths 显示项目配置、有效台账与私有数据位置
31
+ sec doctor 检查工具依赖及有效模型,不运行扫描
32
+ sec auth [--json] 查询 sec 保存的登录方式与环境 key 优先级
33
+ sec config [get|set|unset <key> [<value>]] [--local|--project]
34
+ 默认用户级与 --local 只支持 baseRef
35
+ --project 管理仓库根 sec.config.json,路径均相对仓库根
36
+ 项目项:policyFile/reportTemplate/trackerFile/summaryDir/reviewDir
37
+ knowledgeBase(JSON数组)/idPrefix/baseRef/model/effort/failOnSeverity
38
+ sec aliases [--install] [--shell zsh|bash]
39
+ 输出或安装 sec:xxx shell 快捷命令(含 sec:init project)
40
+ sec setup [--api-key] [--base-url <url>] [--model <model>] [--effort <effort>] [--base <ref>] [--no-aliases]
41
+ sec --version
42
+
43
+ --api-key 不接收 key 值;不要把凭据放在命令行。
44
+ scan/diff/branch/verify 均支持 --model <model> 和 --effort <effort> 单次覆盖。
45
+ 默认 gpt-6.1-sol + high;优先级:单次参数 → 项目共享设置 → setup 个人设置 → 工具默认值。
46
+ 默认中危及以上退出 1,低危警告放行;项目可用 failOnSeverity 调整门禁。
47
+ 错误或证据不足退出 2,取消保留非零状态。退出 0 不代表整个项目无漏洞。
48
+ 正式 scan/diff/branch 与非空 verify 队列会请求模型并消耗额度。
49
+ init(含 dry-run)、branch --dry-run、list、update、paths、doctor、auth、config、help 不请求模型。
50
+ init 创建缺失文件并补充 .gitignore,已有规范、模板和台账保留;--dry-run 不写文件。
51
+ 启用项目配置后,扫描可更新共享台账及忽略的脱敏摘要;不自动修改业务源码、暂存、提交、推送或 fetch。
52
+ 未配置共享文件时沿用本机私有台账与报告;完整原始证据及凭据始终保存在仓库外。
53
+ 项目规范和 knowledgeBase 自动提供扫描上下文;显式 --knowledge-base 可追加本次材料。
54
+ 项目路径以 Git 仓库根为基准;显式 --knowledge-base 相对当前工作目录。项目内容不随 npm 包分发。
55
+ `);
56
+ }
57
+ const rootDirectory = () =>
58
+ execFileSync('git', ['rev-parse', '--show-toplevel'], {
59
+ encoding: 'utf8',
60
+ stdio: 'pipe'
61
+ }).trim();
62
+ export async function main(args = process.argv.slice(2)) {
63
+ if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
64
+ console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
65
+ return 0;
66
+ }
67
+ let [command = 'help', ...rest] = args;
68
+ if (command.startsWith('sec:')) command = command.slice(4);
69
+ if (command === '--help' || command === '-h') command = 'help';
70
+ if (!commands.includes(command)) throw new Error('Unknown command. Run sec help.');
71
+ if (command === 'help' || rest.includes('--help')) {
72
+ help();
73
+ return 0;
74
+ }
75
+ if (command === 'setup') return (await import('./setup.mjs')).main(rest);
76
+ if (command === 'auth') return (await import('./auth.mjs')).main(rest);
77
+ if (command === 'init') return (await import('./project-init.mjs')).main(rest);
78
+ if (command === 'config') return (await import('./config-command.mjs')).main(rest);
79
+ if (['scan', 'diff', 'branch'].includes(command))
80
+ return (await import('./scan.mjs')).main(
81
+ { scan: 'full', diff: 'staged', branch: 'branch' }[command],
82
+ rest
83
+ );
84
+ if (command === 'verify') return (await import('./verify.mjs')).main(rest);
85
+ if (command === 'update') {
86
+ if (!rest.length) {
87
+ console.log('用法:sec update SEC-001 [SEC-002 ...];只排队,不代表修复。');
88
+ return 0;
89
+ }
90
+ const ids = rest.flatMap((arg) => arg.trim().split(/\s+/)).filter(Boolean);
91
+ if (!ids.length || ids.some((id) => !findingId(id)))
92
+ throw new Error('Use complete finding IDs such as SEC-001.');
93
+ return (await import('./verify.mjs')).main(['--pending', ...ids]);
94
+ }
95
+ if (command === 'aliases') {
96
+ const { values } = parseArgs({
97
+ args: rest,
98
+ options: { install: { type: 'boolean' }, shell: { type: 'string' } }
99
+ });
100
+ if (values.install) installAliases({ shell: values.shell });
101
+ else console.log(aliasBlock());
102
+ return 0;
103
+ }
104
+ if (command === 'list') {
105
+ const { values } = parseArgs({
106
+ args: rest,
107
+ options: { severity: { type: 'string' }, all: { type: 'boolean' } }
108
+ });
109
+ console.log(
110
+ renderFindings(
111
+ readTracker(
112
+ trackerFile(rootDirectory()),
113
+ true,
114
+ readProjectConfig(rootDirectory())?.idPrefix ?? 'SEC'
115
+ ).tracker,
116
+ {
117
+ severity: values.severity,
118
+ all: values.all
119
+ }
120
+ )
121
+ );
122
+ return 0;
123
+ }
124
+ if (rest.length) throw new Error('Unknown arguments. Run sec help.');
125
+ if (command === 'paths') {
126
+ const root = rootDirectory();
127
+ console.log(
128
+ JSON.stringify(
129
+ {
130
+ config: configFile(),
131
+ repositoryConfig: repositoryConfigFile(root),
132
+ projectConfig: projectConfigFile(root),
133
+ project: readProjectConfig(root),
134
+ repositoryState: repositoryDirectory(root),
135
+ ledger: trackerFile(root),
136
+ runs: `${repositoryDirectory(root)}/runs`
137
+ },
138
+ null,
139
+ 2
140
+ )
141
+ );
142
+ return 0;
143
+ }
144
+ if (command === 'doctor') {
145
+ const { engineEnvironment, ensureEngine } = await import('./engine.mjs');
146
+ const { readConfig, modelSettings, repositoryModelSettings } = await import('./config.mjs');
147
+ const { findPython } = await import('./runtime.mjs');
148
+ const env = engineEnvironment();
149
+ let root;
150
+ try {
151
+ root = rootDirectory();
152
+ } catch {
153
+ /* doctor also works outside Git. */
154
+ }
155
+ const settings = root
156
+ ? repositoryModelSettings(root, readConfig())
157
+ : modelSettings(readConfig());
158
+ console.log(
159
+ `Git: ${execFileSync('git', ['--version'], { encoding: 'utf8', stdio: 'pipe' }).trim()}`
160
+ );
161
+ console.log(`Python: ${findPython(env)}`);
162
+ console.log(`扫描引擎: ${ensureEngine(env)}`);
163
+ console.log(`配置目录: ${userDirectories().config}`);
164
+ console.log(`默认模型: ${settings.model};推理强度: ${settings.effort}`);
165
+ console.log('依赖可用;未检查真实认证、额度或模型权限。');
166
+ return 0;
167
+ }
168
+ return 0;
169
+ }
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { parseArgs } from 'node:util';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import {
5
+ projectConfigFile,
6
+ readProjectConfig,
7
+ saveProjectConfig,
8
+ projectKeys,
9
+ validateProjectConfig,
10
+ projectPath
11
+ } from './project.mjs';
12
+ import { readTracker } from './tracker.mjs';
13
+ import {
14
+ assertOutsideRepository,
15
+ configFile,
16
+ readConfig,
17
+ readRepositoryConfig,
18
+ repositoryConfigFile,
19
+ saveConfig,
20
+ saveRepositoryConfig,
21
+ validateConfig
22
+ } from './config.mjs';
23
+
24
+ export function main(
25
+ args = [],
26
+ { env = process.env, cwd = process.cwd(), log = console.log } = {}
27
+ ) {
28
+ const { values, positionals } = parseArgs({
29
+ args,
30
+ options: { local: { type: 'boolean' }, project: { type: 'boolean' } },
31
+ allowPositionals: true
32
+ });
33
+ const [action, key, value] = positionals;
34
+ if (values.local && values.project) throw new Error('Choose --local or --project, not both.');
35
+ if (
36
+ positionals.length &&
37
+ (!['get', 'set', 'unset'].includes(action) ||
38
+ !(values.project ? projectKeys : ['baseRef']).includes(key) ||
39
+ positionals.length !== (action === 'set' ? 3 : 2))
40
+ )
41
+ throw new Error(
42
+ 'Use sec config [get|set|unset <key> [<value>]] [--local|--project]. User/local scope supports baseRef; project scope supports shared workflow settings.'
43
+ );
44
+ if (action === 'set' && !values.project) validateConfig({ schemaVersion: 1, baseRef: value });
45
+ let root;
46
+ try {
47
+ root = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
48
+ env,
49
+ encoding: 'utf8',
50
+ stdio: 'pipe'
51
+ }).trim();
52
+ } catch {
53
+ if (values.local || values.project)
54
+ throw new Error('Run sec config --local/--project inside the target Git repository.');
55
+ }
56
+ const user = readConfig(env);
57
+ if (values.project) {
58
+ const path = projectConfigFile(root);
59
+ const previous = existsSync(path)
60
+ ? readFileSync(projectPath(root, 'sec.config.json', { kind: 'file' }), 'utf8')
61
+ : null;
62
+ const project = readProjectConfig(root) ?? { schemaVersion: 1 };
63
+ const fallback =
64
+ key === 'baseRef'
65
+ ? (user.baseRef ?? 'origin/HEAD')
66
+ : key === 'model'
67
+ ? (user.model ?? 'gpt-6.1-sol')
68
+ : key === 'effort'
69
+ ? (user.effort ?? 'high')
70
+ : undefined;
71
+ if (action === 'get') {
72
+ const result = project[key] ?? fallback;
73
+ log(typeof result === 'object' ? JSON.stringify(result) : (result ?? '未设置'));
74
+ return 0;
75
+ }
76
+ if (action === 'set' || action === 'unset') {
77
+ if (action === 'set') {
78
+ if (key === 'knowledgeBase') {
79
+ try {
80
+ project[key] = JSON.parse(value);
81
+ } catch {
82
+ throw new Error('knowledgeBase expects a JSON array, for example ["docs/context.md"].');
83
+ }
84
+ } else project[key] = value;
85
+ } else delete project[key];
86
+ validateProjectConfig(project, root);
87
+ if (project.trackerFile) {
88
+ const tracker = projectPath(root, project.trackerFile, { kind: 'file' });
89
+ if (existsSync(tracker)) readTracker(tracker, false, project.idPrefix ?? 'SEC');
90
+ }
91
+ if (action === 'set' || previous !== null) saveProjectConfig(root, project, previous);
92
+ log(`项目共享配置:${path}。未调用模型;请检查后自行提交。`);
93
+ return 0;
94
+ }
95
+ log(
96
+ `项目共享配置:${path}${previous === null ? '(未创建;sec init project 可生成通用模板)' : ''}`
97
+ );
98
+ log(JSON.stringify(project, null, 2));
99
+ return 0;
100
+ }
101
+
102
+ const config = values.local ? readRepositoryConfig(root, env) : user;
103
+ const path = values.local ? repositoryConfigFile(root, env) : configFile(env);
104
+ const inherited = values.local
105
+ ? (readProjectConfig(root)?.baseRef ?? user.baseRef ?? 'origin/HEAD')
106
+ : 'origin/HEAD';
107
+ const scope = values.local ? '当前仓库' : '用户级(影响所有未单独配置的仓库)';
108
+ if (action === 'get') {
109
+ log(config.baseRef ?? inherited);
110
+ return 0;
111
+ }
112
+ if (action === 'set' || action === 'unset') {
113
+ const changed = action === 'set' ? config.baseRef !== value : Object.hasOwn(config, 'baseRef');
114
+ if (changed) {
115
+ if (root) assertOutsideRepository(root, path);
116
+ if (action === 'set') config.baseRef = value;
117
+ else delete config.baseRef;
118
+ if (values.local) saveRepositoryConfig(root, config, env);
119
+ else saveConfig(config, env);
120
+ }
121
+ log(
122
+ `${scope}默认比较目标:${config.baseRef ?? inherited}${config.baseRef == null ? '(继承/回退)' : ''}`
123
+ );
124
+ log(`配置文件:${path}。未调用模型、fetch 或改动 Git 引用。`);
125
+ return 0;
126
+ }
127
+ log(`作用域:${scope}`);
128
+ log(`配置文件:${path}`);
129
+ log(`已保存 baseRef:${config.baseRef ?? '未设置'}`);
130
+ log(`本作用域默认比较目标:${config.baseRef ?? inherited}`);
131
+ if (!values.local) log('当前仓库覆盖请用 sec config --local 查看;单次 --base 优先于所有配置。');
132
+ return 0;
133
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,207 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readProjectConfig, projectPath } from './project.mjs';
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ writeFileSync
11
+ } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
14
+
15
+ export const engineVersion = '0.8.3';
16
+ export const defaultModel = 'gpt-6.1-sol';
17
+ export const defaultEffort = 'high';
18
+ export const commands = [
19
+ 'setup',
20
+ 'doctor',
21
+ 'auth',
22
+ 'config',
23
+ 'init',
24
+ 'scan',
25
+ 'diff',
26
+ 'branch',
27
+ 'list',
28
+ 'update',
29
+ 'verify',
30
+ 'paths',
31
+ 'aliases',
32
+ 'help'
33
+ ];
34
+ export function userDirectories(env = process.env) {
35
+ const home = env.HOME || homedir();
36
+ return {
37
+ config: resolve(env.XDG_CONFIG_HOME || join(home, '.config'), 'sec'),
38
+ state: resolve(
39
+ env.SEC_STATE_DIR || join(env.XDG_STATE_HOME || join(home, '.local/state'), 'sec')
40
+ )
41
+ };
42
+ }
43
+ export const configFile = (env = process.env) => join(userDirectories(env).config, 'config.json');
44
+ export function readConfig(env = process.env) {
45
+ const path = configFile(env);
46
+ if (!existsSync(path)) return { schemaVersion: 1 };
47
+ const stat = lstatSync(path);
48
+ if (!stat.isFile() || stat.isSymbolicLink())
49
+ throw new Error('User configuration must be a regular file.');
50
+ let config;
51
+ try {
52
+ config = JSON.parse(readFileSync(path, 'utf8'));
53
+ } catch {
54
+ throw new Error('Cannot parse user configuration. Run sec setup to inspect or replace it.');
55
+ }
56
+ validateConfig(config);
57
+ return config;
58
+ }
59
+ export function validateConfig(config) {
60
+ if (
61
+ !config ||
62
+ config.schemaVersion !== 1 ||
63
+ Object.keys(config).some(
64
+ (key) => !['schemaVersion', 'baseUrl', 'model', 'effort', 'baseRef'].includes(key)
65
+ )
66
+ ) {
67
+ throw new Error(
68
+ 'Unsupported user configuration fields or version. API keys belong in the engine login store.'
69
+ );
70
+ }
71
+ for (const key of ['model', 'effort', 'baseRef']) {
72
+ if (
73
+ config[key] != null &&
74
+ (typeof config[key] !== 'string' ||
75
+ !config[key].trim() ||
76
+ /[\x00-\x20\x7f]/.test(config[key]) ||
77
+ config[key].startsWith('-'))
78
+ ) {
79
+ throw new Error(`Invalid ${key} in user configuration.`);
80
+ }
81
+ }
82
+ if (config.baseUrl != null) {
83
+ if (typeof config.baseUrl !== 'string') throw new Error('Invalid provider URL.');
84
+ const url = new URL(config.baseUrl);
85
+ if (
86
+ !['https:', 'http:'].includes(url.protocol) ||
87
+ url.username ||
88
+ url.password ||
89
+ url.search ||
90
+ url.hash
91
+ ) {
92
+ throw new Error(
93
+ 'Provider URL must use HTTP(S) without credentials, query parameters or fragments.'
94
+ );
95
+ }
96
+ if (!config.model) throw new Error('A custom provider URL requires an explicit model.');
97
+ }
98
+ }
99
+ export function modelSettings(config, overrides = {}) {
100
+ validateConfig(config);
101
+ const settings = {
102
+ model: overrides.model ?? config.model ?? defaultModel,
103
+ effort: overrides.effort ?? config.effort ?? defaultEffort
104
+ };
105
+ validateConfig({ schemaVersion: 1, ...settings });
106
+ return settings;
107
+ }
108
+ const within = (root, path) => {
109
+ const rel = relative(root, path);
110
+ return !rel || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../'));
111
+ };
112
+ export function assertOutsideRepository(root, path) {
113
+ const canonicalRoot = realpathSync(root);
114
+ let existing = resolve(path);
115
+ while (!existsSync(existing)) existing = dirname(existing);
116
+ const canonical = resolve(realpathSync(existing), relative(existing, resolve(path)));
117
+ if (within(canonicalRoot, canonical))
118
+ throw new Error('Private configuration and state must stay outside the scanned repository.');
119
+ }
120
+ export function repositoryDirectory(root, env = process.env) {
121
+ const canonical = realpathSync(root);
122
+ const id = createHash('sha256').update(canonical).digest('hex').slice(0, 24);
123
+ const result = join(userDirectories(env).state, 'repositories', id);
124
+ assertOutsideRepository(canonical, result);
125
+ return result;
126
+ }
127
+ export const repositoryConfigFile = (root, env = process.env) =>
128
+ join(repositoryDirectory(root, env), 'config.json');
129
+ function validateRepositoryConfig(config) {
130
+ validateConfig(config);
131
+ if (Object.keys(config).some((key) => !['schemaVersion', 'baseRef'].includes(key)))
132
+ throw new Error('Repository configuration only supports baseRef.');
133
+ }
134
+ export function readRepositoryConfig(root, env = process.env) {
135
+ const path = repositoryConfigFile(root, env);
136
+ if (!existsSync(path)) return { schemaVersion: 1 };
137
+ const stat = lstatSync(path);
138
+ if (!stat.isFile() || stat.isSymbolicLink())
139
+ throw new Error('Repository configuration must be a regular file.');
140
+ let config;
141
+ try {
142
+ config = JSON.parse(readFileSync(path, 'utf8'));
143
+ } catch {
144
+ throw new Error('Cannot parse repository configuration. Inspect the file shown by sec paths.');
145
+ }
146
+ validateRepositoryConfig(config);
147
+ return config;
148
+ }
149
+ export function branchBaseRef(root, config, override, env = process.env) {
150
+ // An explicit one-shot target does not depend on a repository default.
151
+ if (override !== undefined) return override;
152
+ return (
153
+ readRepositoryConfig(root, env).baseRef ??
154
+ readProjectConfig(root)?.baseRef ??
155
+ config.baseRef ??
156
+ 'origin/HEAD'
157
+ );
158
+ }
159
+ export const trackerFile = (root, env = process.env) => {
160
+ const project = readProjectConfig(root);
161
+ return project?.trackerFile
162
+ ? projectPath(root, project.trackerFile, { kind: 'file' })
163
+ : join(repositoryDirectory(root, env), 'ledger.json');
164
+ };
165
+ export function repositoryModelSettings(root, config, overrides = {}) {
166
+ const project = readProjectConfig(root);
167
+ return modelSettings(config, {
168
+ model: project?.model,
169
+ effort: project?.effort,
170
+ ...Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined))
171
+ });
172
+ }
173
+ export function privateDirectory(path) {
174
+ // Validate before creating anything, including a dangling symlink.
175
+ for (
176
+ let current = resolve(path), depth = 0;
177
+ current !== dirname(current) && depth < 3;
178
+ current = dirname(current), depth++
179
+ ) {
180
+ try {
181
+ if (lstatSync(current).isSymbolicLink())
182
+ throw new Error('Private directories must not contain symlinks.');
183
+ } catch (error) {
184
+ if (error.code !== 'ENOENT') throw error;
185
+ }
186
+ }
187
+ mkdirSync(path, { recursive: true, mode: 0o700 });
188
+ }
189
+ export function saveConfig(config, env = process.env) {
190
+ validateConfig(config);
191
+ saveSettingsFile(configFile(env), config);
192
+ }
193
+ export function saveRepositoryConfig(root, config, env = process.env) {
194
+ validateRepositoryConfig(config);
195
+ saveSettingsFile(repositoryConfigFile(root, env), config);
196
+ }
197
+ function saveSettingsFile(path, config) {
198
+ privateDirectory(dirname(path));
199
+ if (existsSync(path) && (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile()))
200
+ throw new Error('Refusing to replace a nonregular configuration file.');
201
+ const temporary = `${path}.${randomUUID()}.tmp`;
202
+ writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, {
203
+ flag: 'wx',
204
+ mode: 0o600
205
+ });
206
+ renameSync(temporary, path);
207
+ }