clilinkapi 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/README.md +161 -0
- package/clilinkapi.example.json +12 -0
- package/dist/src/auth.js +9 -0
- package/dist/src/cli.js +229 -0
- package/dist/src/config.js +98 -0
- package/dist/src/errors.js +24 -0
- package/dist/src/permissions.js +64 -0
- package/dist/src/providers/codex.js +201 -0
- package/dist/src/providers/registry.js +3 -0
- package/dist/src/providers/rpc.js +171 -0
- package/dist/src/providers/runtime.js +98 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/redaction.js +37 -0
- package/dist/src/requests.js +76 -0
- package/dist/src/sandbox.js +63 -0
- package/dist/src/server.js +164 -0
- package/dist/src/sessions.js +19 -0
- package/dist/src/startup.js +46 -0
- package/dist/src/tool-sessions.js +54 -0
- package/docs/architecture.md +13 -0
- package/docs/configuration.md +463 -0
- package/docs/n8n.md +43 -0
- package/docs/security.md +32 -0
- package/docs/verification.md +28 -0
- package/examples/client.mjs +13 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# CliLinkAPI
|
|
2
|
+
|
|
3
|
+
Native Node.js/strict TypeScript gateway for a **ChatGPT-authenticated Codex** runtime. The clilinkapi key protects this HTTP service; it is not an OpenAI API key. No containers, VMs, direct OpenAI API client, browser-cookie extraction, or unofficial ChatGPT endpoints are used.
|
|
4
|
+
|
|
5
|
+
**Current delivery status:** HTTP routing, Codex app-server adapter, discovery, permissions profiles, setup, cancellation, and tests are implemented. By default, native Windows agent execution is attempted without isolation qualification probes; setting `provider.allowUnqualifiedWindowsExecution=false` restores the platform block. The available Windows host failed sandbox initialization. Linux/macOS execution requires the real native isolation probes to pass on that host; those platforms have not been executed during this build. A successful live Codex completion is still unverified because runtime ChatGPT login and private configuration were unavailable. See [verification](docs/verification.md) and [security](docs/security.md). This is not a claim of a production-qualified cross-platform release.
|
|
6
|
+
|
|
7
|
+
## Install natively
|
|
8
|
+
|
|
9
|
+
Install the published CLI with Node.js 22 or newer:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g clilinkapi
|
|
13
|
+
clilinkapi
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use `clilinkapi serve` to start with the default configuration, or `clilinkapi serve "/absolute/path/to/clilinkapi.json"` to select a configuration file. Run `clilinkapi` without arguments for the available setup and login commands.
|
|
17
|
+
|
|
18
|
+
To build from source:
|
|
19
|
+
|
|
20
|
+
Install Node.js 22 or 24 LTS and use a native shell:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm ci
|
|
24
|
+
npm run check
|
|
25
|
+
npm test
|
|
26
|
+
npm run build
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Windows:** PowerShell on native Windows x64/arm64. Setup, ACLs, login, model discovery and HTTP functions run natively; unqualified coding-agent execution is enabled by default (see below). WSL, containers and VMs are not workarounds used by this project.
|
|
30
|
+
- **Linux:** native x64/arm64; install distribution `bubblewrap` (`sudo apt install bubblewrap` on Debian/Ubuntu, `sudo dnf install bubblewrap` on Fedora). User namespaces and the applicable AppArmor policy must permit its execution. Do not disable security protections merely to make the test pass.
|
|
31
|
+
- **macOS:** native x64/arm64; Codex uses Seatbelt. The OS must permit the native sandbox. Run the integration tests on the intended runtime account.
|
|
32
|
+
|
|
33
|
+
The runtime is pinned to Codex CLI/SDK **0.155.0**. Package installation includes the official platform binary. No global `codex` command is needed. Changes to the pinned version require requalification of protocol, permission profiles and sandbox tests.
|
|
34
|
+
|
|
35
|
+
## Configure and sign in
|
|
36
|
+
|
|
37
|
+
For a standalone command, run `npm link` once after building. Then use `clilinkapi serve`, `clilinkapi doctor`, or `clilinkapi serve "C:/path/to/clilinkapi.json"` from any directory. Running `clilinkapi` alone shows help. This links the command to this checkout and still requires Node.js; rebuild after source changes. On Windows, use `clilinkapi.cmd` if PowerShell blocks the generated script. See the [configuration guide](docs/configuration.md#1-install-dependencies) for setup details.
|
|
38
|
+
|
|
39
|
+
See the [complete configuration guide](docs/configuration.md) for a full JSON template, every field and default, Windows execution settings, LAN access, n8n setup, verification commands, and troubleshooting.
|
|
40
|
+
|
|
41
|
+
Copy `clilinkapi.example.json` to a temporary template and edit its absolute paths. Only placeholders belong in version control. Use `/home/your-user/.clilinkapi/codex` on Linux or `/Users/your-user/.clilinkapi/codex` on macOS for `provider.codexHome`; the example uses Windows paths. Both workspace directories must already exist. Remove unused workspace entries.
|
|
42
|
+
|
|
43
|
+
Keep private configuration and Codex home outside **every** workspace, preferably in a dedicated service account's private directory. Workspaces cannot overlap or be filesystem roots. The service rejects custom Codex configuration, hooks, plugins, rules and skills in its dedicated Codex home. Project `.codex`/`.agents` folders may remain in place: the runtime treats the workspace as untrusted and skips project configuration; project skills in `.agents/skills` are allowed by default. Set `provider.allowProjectSkills` to `false` and restart the server to disable workspace skills. Bundled runtime skills remain disabled. Symbolic links and Windows junctions are allowed by default when their resolved targets stay inside the same workspace. Set `provider.allowSymbolicLinks` to `false` and restart to reject all symbolic links and junctions. Broken links, directory cycles, links outside the workspace, and hard-linked files remain blocked. Parent directories may contain Codex configuration or instructions; their presence does not block workspace validation. Use trusted projects and a dedicated runtime account. Do not put credentials in project files.
|
|
44
|
+
|
|
45
|
+
The CLI expands `~`, literal `$HOME`, `${HOME}`, `%USERPROFILE%`, and `$env:USERPROFILE` at the start of its configuration filename. This also works when your shell does not expand them. For example, Command Prompt users can use `npm run clilinkapi -- setup "~/.clilinkapi/clilinkapi.json" ./clilinkapi.example.json`. Paths inside the JSON template still need actual absolute paths; replace `YOUR_USER` and the example workspace paths before setup.
|
|
46
|
+
|
|
47
|
+
PowerShell:
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
npm run clilinkapi -- setup "$HOME/.clilinkapi/clilinkapi.json" ./clilinkapi.example.json
|
|
51
|
+
npm run clilinkapi -- login "$HOME/.clilinkapi/clilinkapi.json"
|
|
52
|
+
npm run clilinkapi -- doctor "$HOME/.clilinkapi/clilinkapi.json"
|
|
53
|
+
npm run clilinkapi -- serve "$HOME/.clilinkapi/clilinkapi.json"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Linux/macOS:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm run clilinkapi -- setup "$HOME/.clilinkapi/clilinkapi.json" ./clilinkapi.example.json
|
|
60
|
+
npm run clilinkapi -- login "$HOME/.clilinkapi/clilinkapi.json"
|
|
61
|
+
npm run clilinkapi -- doctor "$HOME/.clilinkapi/clilinkapi.json"
|
|
62
|
+
npm run clilinkapi -- serve "$HOME/.clilinkapi/clilinkapi.json"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`setup` uses 32 cryptographically random bytes, stores the generated key without printing it, and refuses to overwrite an existing configuration. On Unix it requires private directory/file modes (`0700`/`0600`); on Windows it applies an ACL for the runtime user and SYSTEM. Run under the same account that will run the server. Administrators remain trusted. A private parent directory is required even for key rotation.
|
|
66
|
+
|
|
67
|
+
If an existing configuration fails the ACL check, run `npm run clilinkapi -- secure-config "~/.clilinkapi/clilinkapi.json"` as the runtime user. This secures the dedicated directory, configuration file and configured `codex` child folder without changing file contents or rotating the key. It creates that Codex folder if missing. It requires current-user ownership and refuses unrelated entries, links, your home directory and filesystem roots. A Codex home outside that dedicated child location must be secured separately. On Windows, startup permits read/traverse-only access to the configuration's parent folder, but rejects other users' modification rights; the configuration file and Codex storage still require private ACLs. Setup also secures an existing empty directory automatically. If a configuration already exists, continue with `login` or `doctor` instead of rerunning setup.
|
|
68
|
+
|
|
69
|
+
`login` invokes the official Codex login process with ChatGPT authentication forced. The official process may print a login URL or device code for the human sign-in flow; the clilinkapi never prints stored credentials. If browser login is unavailable, use `login CONFIG --device-auth` where supported by your workspace. Supported Codex credential storage remains in `provider.codexHome` or its supported OS credential store. No credential file is parsed or copied by the clilinkapi. A compatible existing **dedicated** Codex home may be configured; a browser or desktop login is not assumed to be shared.
|
|
70
|
+
|
|
71
|
+
If login fails, check your ChatGPT subscription, workspace Codex permissions, SSO/device-code policy, system clock, and outbound access. Re-run official login as the runtime user for expired sessions. An API-key-authenticated account is rejected. Usage limits are returned as upstream errors, never bypassed. CliLinkAPI startup does not require upstream availability; protected `/v1/models` reports login problems without attempting generation.
|
|
72
|
+
|
|
73
|
+
Update configuration while stopped, preserve private permissions, and restart; there is no hot reload. Rotation:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npm run clilinkapi -- rotate-key /absolute/private/clilinkapi.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Restart and update clients through your own secure secret-distribution method. Running processes retain the previous key until restarted. Do not print the key into a terminal or commit it. Never run multiple clilinkapi instances against overlapping project trees; see the concurrency limitation in the security document.
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
All endpoints require `Authorization: Bearer <clilinkapi-key>`. One shared key gives its holder access to **all** configured workspaces. There is no per-workspace identity or privilege separation between key holders.
|
|
84
|
+
|
|
85
|
+
`GET /v1/models` queries the logged-in runtime's `model/list`; it returns visible models, optionally intersected with `provider.allowedModels`. An empty allowlist means all discovered visible models. `reasoning_efforts` is a clilinkapi extension. No static/fabricated model catalog is shipped. A runtime catalog is not a guarantee of remaining quota or a successful future request.
|
|
86
|
+
|
|
87
|
+
`POST /v1/chat/completions` supports only:
|
|
88
|
+
|
|
89
|
+
| Field | Behavior |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `messages` | Required text-only system, developer, user, assistant messages; at least one user message |
|
|
92
|
+
| `model` | Optional discovered model; otherwise configured default, catalog default, or first discovered model |
|
|
93
|
+
| `reasoning_effort` | Separate native setting validated against that model's discovered efforts |
|
|
94
|
+
| `stream` | Boolean, default false |
|
|
95
|
+
|
|
96
|
+
`X-Workspace-ID` selects a configured workspace. Clients such as n8n may omit it when `compatibility.defaultWorkspace` is configured. Arbitrary directories and session IDs are rejected. New conversations start ephemeral Codex threads; matching external tool results resume the waiting turn using a bounded, one-use continuation. Send conversation history each time. System/developer messages become Codex developer instructions with role labels; Codex's own instructions remain in force. See [n8n setup and limitations](docs/n8n.md).
|
|
97
|
+
|
|
98
|
+
Friendly effort labels `Light`, `Medium`, `Strong` map to `low`, `medium`, `high` only if supported. Other values must appear in discovery. Defaults can be set as `provider.defaultModel` and `provider.defaultReasoning`. Invalid defaults or overrides are rejected, never downgraded. Effort is never added to prompt text and is unrelated to verbosity or output limits.
|
|
99
|
+
|
|
100
|
+
Text messages, function tools and results, `tool_choice: auto/none`, `n: 1`, text response format, and streaming usage requests are supported. Sampling controls, token limits, image inputs, structured output and forced tool choices return 400. Use clients with automatic retries disabled: a failed/timed-out call may already have edited files or invoked an external tool.
|
|
101
|
+
|
|
102
|
+
## Requests without exposing the key
|
|
103
|
+
|
|
104
|
+
The following Node example loads the private config locally. Run it as the client identity authorized to read that config; distribute only the clilinkapi key to other clients using your secure method. Set `CLILINKAPI_CONFIG` to its absolute path.
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import { readFile } from 'node:fs/promises';
|
|
108
|
+
const config = JSON.parse(await readFile(process.env.CLILINKAPI_CONFIG, 'utf8'));
|
|
109
|
+
const headers = {
|
|
110
|
+
Authorization: `Bearer ${config.auth.apiKey}`,
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
'X-Workspace-ID': 'project-a'
|
|
113
|
+
};
|
|
114
|
+
const catalog = await fetch('http://127.0.0.1:3000/v1/models', { headers }).then(r => r.json());
|
|
115
|
+
const model = catalog.data[0].id;
|
|
116
|
+
const request = { model, reasoning_effort: 'Light', messages: [
|
|
117
|
+
{ role: 'user', content: 'Describe this project without modifying it.' }
|
|
118
|
+
] };
|
|
119
|
+
const response = await fetch('http://127.0.0.1:3000/v1/chat/completions', {
|
|
120
|
+
method: 'POST', headers, body: JSON.stringify(request)
|
|
121
|
+
});
|
|
122
|
+
console.log(await response.json());
|
|
123
|
+
|
|
124
|
+
// Real SSE. Incremental final-answer deltas only, with bounded redaction buffering.
|
|
125
|
+
const streaming = await fetch('http://127.0.0.1:3000/v1/chat/completions', {
|
|
126
|
+
method: 'POST', headers, body: JSON.stringify({ ...request, stream: true })
|
|
127
|
+
});
|
|
128
|
+
for await (const chunk of streaming.body) process.stdout.write(chunk);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Use a reasoning effort listed for the selected model if `low` is unavailable. [OpenAI SDK client example](examples/client.mjs) uses `baseURL`, the locally generated clilinkapi key, workspace selection and `maxRetries: 0`.
|
|
132
|
+
|
|
133
|
+
Non-streaming responses contain the actual labelled Codex final answer, with host-path/known-secret redaction, at `choices[0].message.content`. Usage is included only when reported by Codex. Tool activity and commentary are excluded. SSE emits final-answer deltas, then a stop chunk and `[DONE]`; errors after headers are SSE error objects followed by connection close, without a successful terminator. If Codex provides only a buffered answer, streaming returns `stream_unavailable`; it is not turned into fake tokens. Redaction holds a small suffix and unfinished word, so very short responses may appear together. Ordinary chat completions generally do not run commands; **these requests can modify files and execute commands** within their configured permissions.
|
|
134
|
+
|
|
135
|
+
## Deployment and verification
|
|
136
|
+
|
|
137
|
+
CliLinkAPI requires a dedicated Codex home without custom configuration, hooks, plugins, rules, or skills. Codex can create `skills/.system` during normal startup; CliLinkAPI permits the pinned runtime's bundled skill directories and explicitly disables those skills. Other skill directories remain rejected. A startup rejection identifies the blocked entry so it can be relocated without deleting credentials.
|
|
138
|
+
|
|
139
|
+
Bind to localhost by default. For network deployment terminate HTTPS at a maintained reverse proxy, disable response buffering for SSE, set upstream timeouts above the clilinkapi timeout, apply request-size/rate limits, and firewall direct access to the clilinkapi port. Direct TLS is not implemented. Do not transmit bearer keys over public plaintext HTTP. No permissive CORS policy is installed.
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
npm run check
|
|
143
|
+
npm test
|
|
144
|
+
npm run build
|
|
145
|
+
npm run test:isolation
|
|
146
|
+
# Set CLILINKAPI_CONFIG to an actual private configuration after ChatGPT login:
|
|
147
|
+
npm run test:live
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`test:live` starts a local clilinkapi and requests a real response through HTTP, then checks a temporary permitted file and denied external sentinel reads/writes through further HTTP requests. It cleans up only those uniquely named test files. On Linux/macOS, each eligible generation first probes real sandbox reads, writes, link escapes and subprocess inheritance. Windows generation skips these probes by default; native sandbox initialization may still fail. CI configures Node 22/24 on Windows/Linux/macOS; it does not store ChatGPT credentials or claim to run authenticated live tests.
|
|
151
|
+
|
|
152
|
+
See [architecture and adapter guide](docs/architecture.md), [security boundaries](docs/security.md), and [checks executed](docs/verification.md).
|
|
153
|
+
|
|
154
|
+
## Configure unqualified Windows execution
|
|
155
|
+
|
|
156
|
+
`provider.allowUnqualifiedWindowsExecution` defaults to `true` when omitted. Set it to `false` in your private configuration to block Windows execution, then restart the server. Existing explicit `false` values remain respected.
|
|
157
|
+
|
|
158
|
+
This setting bypasses the Windows platform gate and skips the per-generation isolation probes on Windows only. It does not certify read, ACL, descendant-process, or network isolation. Native permission profiles are still requested; Codex may still reject a turn or command if the Windows sandbox cannot initialize. There is no automatic unrestricted fallback. Linux/macOS continue to require their probes regardless of this setting.
|
|
159
|
+
|
|
160
|
+
The startup banner and `doctor` report unqualified execution explicitly. Authentication, configuration ACL checks, workspace validation, and dedicated Codex home checks remain enabled. Use a dedicated runtime account and trusted clients; workspace confinement is not verified in this mode.
|
|
161
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"server": { "host": "127.0.0.1", "port": 3000, "timeoutMs": 180000, "maxConcurrency": 2, "maxBodyBytes": 262144 },
|
|
3
|
+
"auth": { "apiKey": "REPLACE_WITH_A_SECURE_RANDOM_KEY" },
|
|
4
|
+
"compatibility": { "defaultWorkspace": "project-a", "toolTimeoutMs": 300000, "maxPendingTools": 8 },
|
|
5
|
+
"provider": { "type": "codex", "authentication": "chatgpt", "codexHome": "C:/Users/YOUR_USER/.clilinkapi/codex", "allowedModels": [], "allowUnqualifiedWindowsExecution": true, "allowProjectSkills": true, "allowSymbolicLinks": true },
|
|
6
|
+
"workspaces": {
|
|
7
|
+
"project-a": { "path": "C:/Projects/project-a", "access": "read-write" },
|
|
8
|
+
"reference-docs": { "path": "C:/Documents/reference", "access": "read-only" }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
package/dist/src/auth.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { CliLinkAPIError } from './errors.js';
|
|
3
|
+
export function authenticate(header, key) {
|
|
4
|
+
const supplied = header?.match(/^Bearer ([^\s]+)$/)?.[1] ?? '';
|
|
5
|
+
const digest = (s) => createHash('sha256').update(s).digest();
|
|
6
|
+
if (!timingSafeEqual(digest(supplied), digest(key)) || !supplied)
|
|
7
|
+
throw new CliLinkAPIError(401, 'invalid_api_key', 'Missing or invalid clilinkapi API key.');
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile, rename, unlink, realpath, readdir, lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { loadConfig, parseConfig, validatePaths, readJson } from './config.js';
|
|
9
|
+
import { protect, verifyPrivate, verifyOwner, verifyConfigDirectory } from './permissions.js';
|
|
10
|
+
import { createServer } from './server.js';
|
|
11
|
+
import { createProvider } from './providers/registry.js';
|
|
12
|
+
import { codexBinary, runtimeEnv, verifyRuntime } from './providers/runtime.js';
|
|
13
|
+
import { requireNativePlatform } from './sandbox.js';
|
|
14
|
+
import { normalizeError } from './errors.js';
|
|
15
|
+
import { startupMessage } from './startup.js';
|
|
16
|
+
export const defaultConfig = () => path.join(os.homedir(), '.clilinkapi', 'clilinkapi.json');
|
|
17
|
+
export function resolveConfigFilename(filename) {
|
|
18
|
+
// cmd.exe leaves $HOME literal; quoted tilde paths also need application expansion.
|
|
19
|
+
const homePrefix = /^(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)(?=[\\/]|$)/i;
|
|
20
|
+
const expanded = filename.replace(homePrefix, () => os.homedir());
|
|
21
|
+
if (!path.isAbsolute(expanded))
|
|
22
|
+
throw new Error('Configuration filename must be absolute. Use "~/.clilinkapi/clilinkapi.json" or a full path such as "C:/Users/your-user/.clilinkapi/clilinkapi.json".');
|
|
23
|
+
return path.normalize(expanded);
|
|
24
|
+
}
|
|
25
|
+
async function privateDirectory(directory) {
|
|
26
|
+
const created = await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (created)
|
|
28
|
+
await protect(directory, true);
|
|
29
|
+
else {
|
|
30
|
+
// Setup can safely secure an existing empty directory, but must not change
|
|
31
|
+
// permissions on a shared/nonempty parent as an implicit side effect.
|
|
32
|
+
await verifyOwner(directory);
|
|
33
|
+
if ((await readdir(directory)).length === 0)
|
|
34
|
+
await protect(directory, true);
|
|
35
|
+
}
|
|
36
|
+
await verifyPrivate(directory);
|
|
37
|
+
}
|
|
38
|
+
export async function secureConfig(filename) {
|
|
39
|
+
filename = resolveConfigFilename(filename);
|
|
40
|
+
const directory = path.dirname(filename);
|
|
41
|
+
await verifyOwner(directory);
|
|
42
|
+
const homeDirectory = await realpath(os.homedir()).catch(() => path.resolve(os.homedir()));
|
|
43
|
+
if (path.parse(directory).root === directory || path.relative(homeDirectory, await realpath(directory)) === '')
|
|
44
|
+
throw new Error('Use a dedicated configuration directory; refusing to change home or filesystem-root permissions.');
|
|
45
|
+
const entries = await readdir(directory);
|
|
46
|
+
const exists = entries.includes(path.basename(filename));
|
|
47
|
+
let codexHome;
|
|
48
|
+
if (exists) {
|
|
49
|
+
await verifyOwner(filename);
|
|
50
|
+
if (!(await lstat(filename)).isFile())
|
|
51
|
+
throw new Error('Configuration must be a regular file.');
|
|
52
|
+
const input = await readJson(filename);
|
|
53
|
+
if (input && typeof input === 'object' && 'provider' in input && input.provider && typeof input.provider === 'object' && 'codexHome' in input.provider) {
|
|
54
|
+
const configured = input.provider.codexHome;
|
|
55
|
+
if (typeof configured !== 'string' || !path.isAbsolute(configured) || path.relative(directory, path.normalize(configured)) !== 'codex')
|
|
56
|
+
throw new Error('secure-config prepares Codex storage only at the dedicated configuration directory/codex path. Configure that location or secure a different Codex home separately.');
|
|
57
|
+
codexHome = path.join(directory, 'codex');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (entries.some(entry => entry !== path.basename(filename) && !(codexHome && entry === 'codex')))
|
|
61
|
+
throw new Error('Configuration directory contains other entries. Use a dedicated directory containing only the configuration file and configured codex folder before repairing permissions.');
|
|
62
|
+
const runtimePaths = [];
|
|
63
|
+
if (codexHome && entries.includes('codex')) {
|
|
64
|
+
const collect = async (target) => {
|
|
65
|
+
await verifyOwner(target);
|
|
66
|
+
const info = await lstat(target);
|
|
67
|
+
runtimePaths.push({ path: target, directory: info.isDirectory() });
|
|
68
|
+
if (info.isDirectory())
|
|
69
|
+
for (const name of await readdir(target))
|
|
70
|
+
await collect(path.join(target, name));
|
|
71
|
+
};
|
|
72
|
+
await collect(codexHome);
|
|
73
|
+
if (!runtimePaths[0]?.directory)
|
|
74
|
+
throw new Error('Codex home must be a directory.');
|
|
75
|
+
}
|
|
76
|
+
await protect(directory, true);
|
|
77
|
+
if (exists) {
|
|
78
|
+
await protect(filename);
|
|
79
|
+
await verifyPrivate(filename);
|
|
80
|
+
}
|
|
81
|
+
if (codexHome) {
|
|
82
|
+
if (!entries.includes('codex')) {
|
|
83
|
+
await mkdir(codexHome, { mode: 0o700 });
|
|
84
|
+
runtimePaths.push({ path: codexHome, directory: true });
|
|
85
|
+
}
|
|
86
|
+
for (const entry of runtimePaths) {
|
|
87
|
+
await protect(entry.path, entry.directory);
|
|
88
|
+
await verifyPrivate(entry.path);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await verifyPrivate(directory);
|
|
92
|
+
}
|
|
93
|
+
export async function setup(filename, template) {
|
|
94
|
+
filename = resolveConfigFilename(filename);
|
|
95
|
+
const input = await readJson(template);
|
|
96
|
+
if (!input || typeof input !== 'object')
|
|
97
|
+
throw new Error('Invalid template.');
|
|
98
|
+
const candidate = input;
|
|
99
|
+
candidate.auth = { apiKey: randomBytes(32).toString('base64url') };
|
|
100
|
+
const config = parseConfig(candidate);
|
|
101
|
+
await privateDirectory(path.dirname(filename));
|
|
102
|
+
await privateDirectory(config.provider.codexHome);
|
|
103
|
+
// Exclusive creation: setup never overwrites an existing key.
|
|
104
|
+
const pending = filename + '.' + randomUUID() + '.tmp';
|
|
105
|
+
try {
|
|
106
|
+
// Reserve destination before writing secrets, then verify workspace boundaries.
|
|
107
|
+
await writeFile(filename, '', { flag: 'wx', mode: 0o600 });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Error('Configuration already exists or cannot be created. Use rotate-key explicitly to rotate a key.');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await protect(filename);
|
|
114
|
+
await validatePaths(config, filename);
|
|
115
|
+
await writeFile(pending, JSON.stringify(config, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
116
|
+
await protect(pending);
|
|
117
|
+
await rename(pending, filename);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
await unlink(pending).catch(() => undefined);
|
|
121
|
+
await unlink(filename).catch(() => undefined);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function rotate(filename) {
|
|
126
|
+
filename = resolveConfigFilename(filename);
|
|
127
|
+
await verifyConfigDirectory(path.dirname(filename));
|
|
128
|
+
await verifyPrivate(filename);
|
|
129
|
+
const input = await readJson(filename);
|
|
130
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
131
|
+
throw new Error('Invalid configuration.');
|
|
132
|
+
// Explicit rotation must also recover from a placeholder/invalid old key.
|
|
133
|
+
// Validate every other setting before atomically replacing the file.
|
|
134
|
+
const config = parseConfig({ ...input, auth: { apiKey: randomBytes(32).toString('base64url') } });
|
|
135
|
+
await validatePaths(config, filename);
|
|
136
|
+
const temp = filename + '.' + randomUUID() + '.tmp';
|
|
137
|
+
try {
|
|
138
|
+
await writeFile(temp, JSON.stringify(config, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
139
|
+
await protect(temp);
|
|
140
|
+
await rename(temp, filename);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await unlink(temp).catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function main() {
|
|
147
|
+
const [command = 'help', configArgument = defaultConfig(), template] = process.argv.slice(2);
|
|
148
|
+
const filename = resolveConfigFilename(configArgument);
|
|
149
|
+
if (command === 'secure-config') {
|
|
150
|
+
await secureConfig(filename);
|
|
151
|
+
console.log('Configuration permissions repaired. File contents and API key were not changed.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (command === 'setup') {
|
|
155
|
+
if (!template)
|
|
156
|
+
throw new Error('Usage: clilinkapi setup ABSOLUTE_CONFIG_PATH TEMPLATE_JSON_PATH');
|
|
157
|
+
await setup(filename, template);
|
|
158
|
+
console.log('Configuration created privately. Key was not printed.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (command === 'rotate-key') {
|
|
162
|
+
await rotate(filename);
|
|
163
|
+
console.log('CliLinkAPI key rotated. Restart the clilinkapi and update clients. Key was not printed.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!['serve', 'login', 'doctor'].includes(command)) {
|
|
167
|
+
console.log('Commands: setup CONFIG TEMPLATE | secure-config CONFIG | rotate-key CONFIG | login CONFIG [--device-auth] | doctor CONFIG | serve CONFIG\nDefault CONFIG: user home/.clilinkapi/clilinkapi.json');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await verifyConfigDirectory(path.dirname(filename));
|
|
171
|
+
await verifyPrivate(filename);
|
|
172
|
+
const config = await loadConfig(filename);
|
|
173
|
+
await verifyPrivate(config.provider.codexHome);
|
|
174
|
+
await verifyRuntime(config.provider.codexHome);
|
|
175
|
+
if (command === 'login') {
|
|
176
|
+
if (template && template !== '--device-auth')
|
|
177
|
+
throw new Error('Only --device-auth is accepted after the config path.');
|
|
178
|
+
const child = spawn(codexBinary(), ['-c', 'forced_login_method="chatgpt"', 'login', ...(template ? [template] : [])], { env: runtimeEnv(config.provider.codexHome), cwd: config.provider.codexHome, stdio: 'inherit', windowsHide: true });
|
|
179
|
+
await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error('Official Codex login did not complete.'))); });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const provider = createProvider(config, await realpath(filename));
|
|
183
|
+
if (command === 'doctor') {
|
|
184
|
+
let failed = false;
|
|
185
|
+
try {
|
|
186
|
+
requireNativePlatform(config.provider.allowUnqualifiedWindowsExecution);
|
|
187
|
+
console.log(process.platform === 'win32' && config.provider.allowUnqualifiedWindowsExecution
|
|
188
|
+
? 'WARNING: Unqualified Windows execution enabled. Isolation probes are skipped; read/ACL/descendant isolation is not verified. Native permission profiles remain requested and may still fail.'
|
|
189
|
+
: 'Native platform is eligible; each generation still requires isolation probes.');
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.log(normalizeError(error).message);
|
|
193
|
+
failed = true;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const models = await provider.models(AbortSignal.timeout(30000));
|
|
197
|
+
console.log(JSON.stringify({ chatgpt_login: true, models: models.map(m => ({ id: m.id, reasoning_efforts: m.efforts })) }));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.log(normalizeError(error).message);
|
|
201
|
+
failed = true;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await provider.close();
|
|
205
|
+
}
|
|
206
|
+
if (failed)
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const app = createServer(config, provider);
|
|
211
|
+
await new Promise((resolve, reject) => { app.server.once('error', reject); app.server.listen(config.server.port, config.server.host, resolve); });
|
|
212
|
+
const address = app.server.address();
|
|
213
|
+
if (address && typeof address !== 'string')
|
|
214
|
+
console.log(startupMessage(config, address));
|
|
215
|
+
let closing = false;
|
|
216
|
+
const close = () => { if (!closing) {
|
|
217
|
+
closing = true;
|
|
218
|
+
void app.close();
|
|
219
|
+
} };
|
|
220
|
+
process.once('SIGINT', close);
|
|
221
|
+
process.once('SIGTERM', close);
|
|
222
|
+
}
|
|
223
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(await realpath(process.argv[1])).href)
|
|
224
|
+
main().catch(error => {
|
|
225
|
+
// Only configuration errors are displayed locally. Never stringify runtime objects.
|
|
226
|
+
console.error(error instanceof Error ? error.message : 'CliLinkAPI startup failed.');
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
});
|
|
229
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { lstat, realpath, readFile, stat, readdir } from 'node:fs/promises';
|
|
4
|
+
import { CliLinkAPIError } from './errors.js';
|
|
5
|
+
export const configSchema = z.strictObject({
|
|
6
|
+
server: z.strictObject({ host: z.string().min(1).default('127.0.0.1'), port: z.number().int().min(1).max(65535).default(3000), timeoutMs: z.number().int().min(1000).max(3600000).default(180000), maxConcurrency: z.number().int().min(1).max(16).default(2), maxBodyBytes: z.number().int().min(1024).max(1048576).default(262144) }).default({ host: '127.0.0.1', port: 3000, timeoutMs: 180000, maxConcurrency: 2, maxBodyBytes: 262144 }),
|
|
7
|
+
auth: z.strictObject({ apiKey: z.string().min(43).max(256).refine(v => !/replace|placeholder|changeme|example|your[_-]?key/i.test(v) && new Set(v).size >= 16, 'Generate a clilinkapi key with setup or rotate-key') }),
|
|
8
|
+
compatibility: z.strictObject({ defaultWorkspace: z.string().optional(), toolTimeoutMs: z.number().int().min(1000).max(1800000).default(300000), maxPendingTools: z.number().int().min(1).max(64).default(8) }).default({ toolTimeoutMs: 300000, maxPendingTools: 8 }),
|
|
9
|
+
provider: z.strictObject({ type: z.literal('codex'), authentication: z.literal('chatgpt'), allowUnqualifiedWindowsExecution: z.boolean().default(true), allowProjectSkills: z.boolean().default(true), allowSymbolicLinks: z.boolean().default(true), codexHome: z.string().min(1), allowedModels: z.array(z.string().regex(/^[a-zA-Z0-9._-]+$/)).default([]), defaultModel: z.string().optional(), defaultReasoning: z.string().optional() }),
|
|
10
|
+
workspaces: z.record(z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/), z.strictObject({ path: z.string(), access: z.enum(['read-only', 'read-write']) })).refine(v => Object.keys(v).length > 0, 'Configure at least one workspace')
|
|
11
|
+
});
|
|
12
|
+
export const inside = (root, target) => { const r = path.relative(root, target); return r === '' || (!r.startsWith('..' + path.sep) && r !== '..' && !path.isAbsolute(r)); };
|
|
13
|
+
export function parseConfig(value) {
|
|
14
|
+
const result = configSchema.safeParse(value);
|
|
15
|
+
if (!result.success)
|
|
16
|
+
throw new Error('Invalid configuration: ' + result.error.issues.map(i => i.path.join('.') + ': ' + i.message).join('; '));
|
|
17
|
+
if (result.data.compatibility.defaultWorkspace && !Object.hasOwn(result.data.workspaces, result.data.compatibility.defaultWorkspace))
|
|
18
|
+
throw new Error('compatibility.defaultWorkspace must name a configured workspace.');
|
|
19
|
+
return result.data;
|
|
20
|
+
}
|
|
21
|
+
export async function canonicalDirectory(directory) {
|
|
22
|
+
if (!path.isAbsolute(directory))
|
|
23
|
+
throw new Error('Workspace and Codex home paths must be absolute native paths.');
|
|
24
|
+
const resolved = await realpath(directory);
|
|
25
|
+
if (!(await stat(resolved)).isDirectory() || path.parse(resolved).root === resolved)
|
|
26
|
+
throw new Error('Directories must exist and must not be filesystem roots.');
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
export async function validatePaths(config, filename) {
|
|
30
|
+
const configPath = await realpath(filename);
|
|
31
|
+
config.provider.codexHome = await canonicalDirectory(config.provider.codexHome);
|
|
32
|
+
const roots = [];
|
|
33
|
+
for (const workspace of Object.values(config.workspaces)) {
|
|
34
|
+
workspace.path = await canonicalDirectory(workspace.path);
|
|
35
|
+
if (inside(workspace.path, configPath) || inside(workspace.path, config.provider.codexHome) || inside(config.provider.codexHome, workspace.path))
|
|
36
|
+
throw new Error('Keep clilinkapi configuration and Codex credential storage outside every workspace.');
|
|
37
|
+
if (roots.some(root => inside(root, workspace.path) || inside(workspace.path, root)))
|
|
38
|
+
throw new Error('Workspace directories must not overlap or alias one another.');
|
|
39
|
+
roots.push(workspace.path);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function loadConfig(filename) {
|
|
43
|
+
if ((await lstat(filename)).isSymbolicLink())
|
|
44
|
+
throw new Error('Configuration must not be a symlink.');
|
|
45
|
+
const config = parseConfig(await readJson(filename));
|
|
46
|
+
await validatePaths(config, filename);
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
export async function readJson(filename) {
|
|
50
|
+
const text = await readFile(filename, 'utf8');
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error('Configuration is not valid JSON. Check syntax locally; file contents are intentionally omitted.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Defense in depth only: native enforcement is still mandatory for races and links
|
|
59
|
+
// created by an executing agent. Hard-linked files are rejected; project configuration
|
|
60
|
+
// is ignored by the runtime's untrusted-project policy.
|
|
61
|
+
export async function inspectWorkspace(workspace, allowSymbolicLinks = true) {
|
|
62
|
+
const projectSkills = [];
|
|
63
|
+
if (await realpath(workspace.path) !== workspace.path)
|
|
64
|
+
throw new CliLinkAPIError(403, 'workspace_changed', 'Workspace identity changed. Restart after reviewing configuration.');
|
|
65
|
+
const walk = async (directory, ancestors) => {
|
|
66
|
+
const resolved = await realpath(directory);
|
|
67
|
+
if (!inside(workspace.path, resolved) || ancestors.has(resolved))
|
|
68
|
+
throw new CliLinkAPIError(403, 'workspace_link', 'Workspace links must stay inside the workspace and must not form directory cycles.');
|
|
69
|
+
const nextAncestors = new Set(ancestors).add(resolved);
|
|
70
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
71
|
+
const filename = path.join(directory, entry.name);
|
|
72
|
+
let info = await lstat(filename);
|
|
73
|
+
if (info.isSymbolicLink()) {
|
|
74
|
+
if (!allowSymbolicLinks)
|
|
75
|
+
throw new CliLinkAPIError(403, 'workspace_link', 'Symbolic links and junctions are disabled by provider.allowSymbolicLinks.');
|
|
76
|
+
let target;
|
|
77
|
+
try {
|
|
78
|
+
target = await realpath(filename);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new CliLinkAPIError(403, 'workspace_link', 'Workspace symbolic links must resolve to an existing target without link loops.');
|
|
82
|
+
}
|
|
83
|
+
if (!inside(workspace.path, target))
|
|
84
|
+
throw new CliLinkAPIError(403, 'workspace_link', 'Symbolic links and junctions must point inside the same workspace.');
|
|
85
|
+
info = await stat(filename);
|
|
86
|
+
}
|
|
87
|
+
if (!info.isDirectory() && info.nlink > 1)
|
|
88
|
+
throw new CliLinkAPIError(403, 'workspace_link', 'Hard-linked files are not supported in workspaces.');
|
|
89
|
+
if (!info.isDirectory() && entry.name.toLowerCase() === 'skill.md')
|
|
90
|
+
projectSkills.push(filename);
|
|
91
|
+
if (info.isDirectory())
|
|
92
|
+
await walk(filename, nextAncestors);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await walk(workspace.path, new Set());
|
|
96
|
+
return projectSkills;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CliLinkAPIError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
constructor(status, code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function normalizeError(error) {
|
|
11
|
+
if (error instanceof CliLinkAPIError)
|
|
12
|
+
return error;
|
|
13
|
+
// Never pass raw upstream errors (which can contain paths, prompts, or credentials).
|
|
14
|
+
const text = error instanceof Error ? error.message : '';
|
|
15
|
+
if (/rate.?limit|quota|usage limit/i.test(text))
|
|
16
|
+
return new CliLinkAPIError(429, 'upstream_rate_limit', 'Codex usage limit reached. Check your ChatGPT workspace subscription.');
|
|
17
|
+
if (/auth|unauthorized|login|token expired|401/i.test(text))
|
|
18
|
+
return new CliLinkAPIError(503, 'upstream_authentication', 'Codex ChatGPT login is missing or expired. Run clilinkapi login as the runtime user.');
|
|
19
|
+
if (/approval|permission|sandbox/i.test(text))
|
|
20
|
+
return new CliLinkAPIError(403, 'execution_denied', 'Codex denied execution under the configured permissions. No escalation was approved.');
|
|
21
|
+
return new CliLinkAPIError(502, 'upstream_error', 'Codex execution failed. Check runtime access and retry deliberately; changes may already have occurred.');
|
|
22
|
+
}
|
|
23
|
+
export const errorBody = (error) => ({ error: { message: error.message, type: error.status === 401 ? 'authentication_error' : error.status < 500 ? 'invalid_request_error' : 'server_error', param: null, code: error.code } });
|
|
24
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { chmod, lstat } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
// Static PowerShell program; the filename is passed through an environment variable,
|
|
6
|
+
// never interpolated into executable code. Restrict to current identity and SYSTEM.
|
|
7
|
+
const readAcl = `$ErrorActionPreference='Stop'; $p=$env:CLILINKAPI_ACL_PATH; $dir=[System.IO.Directory]::Exists($p); $a=if($dir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)};`;
|
|
8
|
+
const aclScript = readAcl + `$s=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; if($a.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $s.Value){$a.SetOwner($s)}; $a.SetAccessRuleProtection($true,$false); foreach($r in @($a.Access)){[void]$a.RemoveAccessRuleSpecific($r)}; $inherit=if($dir){'ContainerInherit,ObjectInherit'}else{'None'}; foreach($id in @($s.Value,'S-1-5-18')) { $sid=[System.Security.Principal.SecurityIdentifier]::new($id); $r=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl',$inherit,'None','Allow'); $a.AddAccessRule($r) }; if($dir){[System.IO.Directory]::SetAccessControl($p,$a)}else{[System.IO.File]::SetAccessControl($p,$a)}`;
|
|
9
|
+
const verifyScript = readAcl + `$s=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; if($a.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $s){exit 2}; foreach($r in $a.Access){$id=$r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value; if($r.AccessControlType -eq 'Allow' -and $id -ne $s -and $id -ne 'S-1-5-18'){exit 3}}`;
|
|
10
|
+
async function windows(script, filename) { await exec('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { env: { ...process.env, CLILINKAPI_ACL_PATH: filename }, windowsHide: true, timeout: 10000 }); }
|
|
11
|
+
export async function protect(filename, directory = false) { if (process.platform === 'win32')
|
|
12
|
+
await windows(aclScript, filename);
|
|
13
|
+
else
|
|
14
|
+
await chmod(filename, directory ? 0o700 : 0o600); }
|
|
15
|
+
export async function verifyOwner(filename) {
|
|
16
|
+
const info = await lstat(filename);
|
|
17
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && info.nlink > 1))
|
|
18
|
+
throw new Error('Secret storage must not be a symbolic or hard link.');
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
try {
|
|
21
|
+
await windows(readAcl + `$s=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; if($a.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $s){exit 2}`, filename);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error('Secret storage must be owned by the current runtime user before permissions can be repaired.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (process.getuid && info.uid !== process.getuid())
|
|
28
|
+
throw new Error('Secret storage must be owned by the current runtime user before permissions can be repaired.');
|
|
29
|
+
}
|
|
30
|
+
export async function verifyPrivate(filename) {
|
|
31
|
+
const info = await lstat(filename);
|
|
32
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && info.nlink > 1))
|
|
33
|
+
throw new Error('Secret storage must not be a symbolic or hard link.');
|
|
34
|
+
if (process.platform === 'win32') {
|
|
35
|
+
try {
|
|
36
|
+
await windows(verifyScript, filename);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Private storage check failed for "${filename}": ACL must grant access only to the runtime user and SYSTEM. Run: npm run clilinkapi -- secure-config CONFIG_PATH`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
|
|
43
|
+
throw new Error('Secret storage must be owned by the runtime user, with mode 0600 (file) or 0700 (directory).');
|
|
44
|
+
}
|
|
45
|
+
// A parent may be traversable/readable without making a protected secret file
|
|
46
|
+
// readable. Reject other identities' directory mutation rights, which could
|
|
47
|
+
// permit replacing that file. The file itself must still pass verifyPrivate.
|
|
48
|
+
export async function verifyConfigDirectory(directory) {
|
|
49
|
+
await verifyOwner(directory);
|
|
50
|
+
if (!(await lstat(directory)).isDirectory())
|
|
51
|
+
throw new Error('Configuration parent must be a directory.');
|
|
52
|
+
if (process.platform !== 'win32') {
|
|
53
|
+
await verifyPrivate(directory);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const script = readAcl + `$s=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; $unsafe=[System.Security.AccessControl.FileSystemRights]::Write -bor [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership; foreach($r in $a.Access){$id=$r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value; if($r.AccessControlType -eq 'Allow' -and $id -ne $s -and $id -ne 'S-1-5-18' -and ($r.FileSystemRights -band $unsafe) -ne 0){exit 3}}`;
|
|
57
|
+
try {
|
|
58
|
+
await windows(script, directory);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error(`Configuration directory "${directory}" permits modification by another account. Run: npm run clilinkapi -- secure-config CONFIG_PATH`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=permissions.js.map
|