sentinelayer-cli 0.44.0 → 0.45.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 +8 -4
- package/package.json +1 -1
- package/src/agents/jules/tools/aidenid-email.js +7 -9
- package/src/agents/jules/tools/frontend-analyze.js +15 -3
- package/src/agents/jules/tools/runtime-audit.js +51 -21
- package/src/ai/aidenid.js +5 -4
- package/src/ai/proxy.js +15 -14
- package/src/audit/orchestrator.js +8 -5
- package/src/auth/admission-scope.js +5 -3
- package/src/auth/credential-destinations.js +221 -0
- package/src/auth/gate.js +4 -4
- package/src/auth/http.js +22 -5
- package/src/auth/service.js +52 -56
- package/src/cli.js +14 -6
- package/src/commands/ai/provision-governance.js +7 -6
- package/src/commands/init.js +25 -16
- package/src/commands/scan.js +6 -4
- package/src/commands/session.js +33 -45
- package/src/commands/spec.js +2 -2
- package/src/commands/watch.js +4 -3
- package/src/config/service.js +37 -0
- package/src/daemon/pulse.js +2 -1
- package/src/daemon/watchdog.js +117 -68
- package/src/legacy-cli.js +119 -39
- package/src/mcp/bridge-inputs.js +223 -0
- package/src/mcp/cli-command-tools.js +157 -7
- package/src/mcp/registry.js +2 -1
- package/src/mcp/session-stdio-server.js +114 -35
- package/src/mcp/smoke.js +27 -11
- package/src/mcp/token-service.js +7 -9
- package/src/memory/retrieval.js +14 -5
- package/src/pocket/ring-owner.js +13 -8
- package/src/review/ai-review.js +2 -2
- package/src/review/dd-report-email-client.js +4 -5
- package/src/session/admission-access.js +13 -10
- package/src/session/admission-auth.js +95 -16
- package/src/session/admission.js +44 -9
- package/src/session/checkpoints.js +11 -10
- package/src/session/file-locks.js +19 -18
- package/src/session/invitations.js +26 -19
- package/src/session/live-source.js +13 -13
- package/src/session/route-classes.js +107 -0
- package/src/session/sync.js +70 -56
- package/src/session/tickets.js +21 -19
- package/src/session/title-sync.js +4 -3
- package/src/telemetry/sync.js +8 -9
package/README.md
CHANGED
|
@@ -206,7 +206,7 @@ required` (or `legacy`).
|
|
|
206
206
|
The owner actions (`approve`, `deny`, `revoke`, `mode`) run on the owner's own
|
|
207
207
|
session. They are unavailable in an agent context, meaning `SENTINELAYER_AGENT_ID`
|
|
208
208
|
is set or agent admission credentials are stored on the machine; owners use the
|
|
209
|
-
web dashboard from such machines. `session access` commands are not
|
|
209
|
+
web dashboard from such machines. `session access` commands are not callable
|
|
210
210
|
through the MCP CLI bridge.
|
|
211
211
|
|
|
212
212
|
Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
|
|
@@ -759,7 +759,7 @@ Use `init-aidenid-adapter` to scaffold a deterministic AIdenID provisioning API
|
|
|
759
759
|
Use `token mint` to request a short-lived hosted MCP bearer token from the Sentinelayer API. Text output hides the bearer value; `--json` is the explicit operator path that includes `accessToken`. The command is blocked from the generated CLI MCP bridge.
|
|
760
760
|
Use `smoke` to verify the hosted MCP resource without printing the bearer. The canonical remote MCP paste URL is `https://api.sentinelayer.com/mcp`; the token audience/resource value is `https://mcp.sentinelayer.com` and is handled by the OAuth/MCP flow. `sl mcp smoke` works today through the CLI direct-mint path; third-party browser connectors such as Claude.ai still require the OAuth browser-flow and connector-registration work to complete before users can authenticate from that product.
|
|
761
761
|
Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, API-authoritative file leases, and attention requests to local MCP clients. File-lease lifecycle operations never write session events; install edit preflights with `sl session guard-install` and remove them with `sl session guard-uninstall` before any CLI rollback. Hosted Claude-web/ChatGPT connectors need a separate HTTPS/OAuth service and are not shipped by the local stdio server. See [docs/mcp.md](docs/mcp.md) and [docs/file-leases.md](docs/file-leases.md).
|
|
762
|
-
Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
|
|
762
|
+
Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The MCP bridge now enforces the human-approval requirement on CLI tools; they are unavailable over MCP until approval is supported. Senti session tools are unaffected. The local stdio server does not list the `sl.*` tools, and a call to one returns a JSON-RPC error (code `-32001`, `data.reason` `human_approval_required`) that says to run the `sl` command in a terminal. A file or directory input to a bridged command must stay inside the workspace (no absolute path, no `..` or symlink out of it). The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
|
|
763
763
|
|
|
764
764
|
MCP operator verification:
|
|
765
765
|
|
|
@@ -770,7 +770,7 @@ MCP operator verification:
|
|
|
770
770
|
`sl mcp smoke --session <senti-session-id> --json`. The smoke mints the
|
|
771
771
|
short-lived bearer in memory, calls `/mcp`, and prints only redacted proof.
|
|
772
772
|
3. Kill switch: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1` and restart the
|
|
773
|
-
MCP host if a CLI bridge or bearer credential is exposed.
|
|
773
|
+
MCP host if a CLI bridge or bearer credential is exposed. In this version the MCP server already refuses every `sl.*` call for human approval, so the switch does not change MCP responses; it is defence in depth for the CLI bridge handler and for approved execution in a future version.
|
|
774
774
|
|
|
775
775
|
Architecture references: local stdio runtime and incident response live in
|
|
776
776
|
[docs/mcp.md](docs/mcp.md); hosted OAuth/session-seat requirements live in
|
|
@@ -817,7 +817,7 @@ Architecture decisions:
|
|
|
817
817
|
If a hosted MCP bearer or bridge command output is exposed:
|
|
818
818
|
|
|
819
819
|
1. Set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`.
|
|
820
|
-
2. Restart the MCP host process so the
|
|
820
|
+
2. Restart the MCP host process. In this version the MCP server already refuses every `sl.*` call for human approval, so the switch does not change MCP responses; it is defence in depth for the CLI bridge handler and for approved execution in a future version.
|
|
821
821
|
3. Run `sentinelayer-cli auth revoke` or revoke the requesting session from the
|
|
822
822
|
dashboard, then re-authenticate with `sentinelayer-cli auth login`.
|
|
823
823
|
4. Remove the exposed value from local logs/transcripts where possible.
|
|
@@ -912,6 +912,10 @@ gh secret list --repo <owner/repo>
|
|
|
912
912
|
- `SENTINELAYER_DISABLE_KEYRING=1` (force file-based credential storage)
|
|
913
913
|
- `AIDENID_API_KEY`, `AIDENID_ORG_ID`, `AIDENID_PROJECT_ID` (used by `sl ai provision-email --execute`)
|
|
914
914
|
|
|
915
|
+
When the CLI sends your SentinelLayer token over HTTP, it sends it only to the API named by `SENTINELAYER_API_URL` (else by `apiUrl` in `~/.sentinelayer/config.yml`, else the default API), or to the pocket gateway named by `SENTI_POCKET_URL`. `POCKET_GATEWAY_URL` is no longer read. The CLI decides this once, when it starts. Custom API origins must be configured: an `--api-url` or `--gateway-url` that names another origin is refused for any request that would carry the token. A project `.sentinelayer.yml` chooses neither the API nor the token: its `apiUrl` and `sentinelayerToken` are not used, nor are its provider keys (`openaiApiKey`, `anthropicApiKey`, `googleApiKey`), which come from the environment or the global config. Watchdog alert channels (`alerts.channels`) also come from the global config only; a channel's `${NAME}` placeholders are filled only from `SLACK_WEBHOOK_URL`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` or `SENTINELAYER_ALERT_*` (any other name, or an unset one, refuses the channel), and Slack alerts go only to `https://hooks.slack.com/`. When a project config sets any of these, the CLI prints one notice saying what to set instead. Requests that carry the token do not follow redirects.
|
|
916
|
+
|
|
917
|
+
The token leaves the CLI in other ways only through operator commands, none of which is available through the MCP bridge: `sl scan setup-secrets` writes it to a GitHub Actions secret with the `gh` CLI (or to the file named by `SENTINELAYER_SECRET_SINK_FILE`, used in tests), and `sl mcp token mint --json` prints a short-lived hosted MCP bearer minted from it. The project token that `sl init` receives for a new scaffold (not your login token) is written to the project's `.env`, its `.sentinelayer/config.json` lockfile and, only when you pass `--inject-secret` (or set `injectSecret` in the interview), a GitHub Actions secret on this directory's own git remote, after `gh` confirms you can write to it. `OPENAI_API_KEY` from your environment is set there only with `--inject-openai-key`. Neither is available through the MCP bridge. Other services' keys (model providers, AIdenID, a memory or embedding service) are sent to their own endpoints.
|
|
918
|
+
|
|
915
919
|
## Layered config (PR 0.2)
|
|
916
920
|
|
|
917
921
|
The CLI supports layered config resolution:
|
package/package.json
CHANGED
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
resolveAidenIdCredentials,
|
|
12
12
|
normalizeAidenIdApiUrl,
|
|
13
13
|
} from "../../../ai/aidenid.js";
|
|
14
|
-
import { readStoredSession } from "../../../auth/session-store.js";
|
|
15
14
|
import { fetchAidenIdCredentials } from "../../../auth/service.js";
|
|
15
|
+
import { isAuthenticated } from "../../../auth/credential-destinations.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* AIdenID email tool definition for agent dispatch.
|
|
@@ -57,16 +57,14 @@ export const AIDENID_EMAIL_TOOL = {
|
|
|
57
57
|
export async function executeAidenIdEmailTool(input, ctx = {}) {
|
|
58
58
|
const operation = String(input.operation || "").trim();
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// no session
|
|
65
|
-
}
|
|
60
|
+
const { resolveActiveAuthSession } = await import("../../../auth/service.js");
|
|
61
|
+
const session = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
|
|
62
|
+
() => null,
|
|
63
|
+
);
|
|
66
64
|
|
|
67
65
|
const makeFetcher = () => {
|
|
68
|
-
if (!session
|
|
69
|
-
return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl,
|
|
66
|
+
if (!isAuthenticated(session)) return null;
|
|
67
|
+
return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, auth: session });
|
|
70
68
|
};
|
|
71
69
|
|
|
72
70
|
const credentials = await resolveAidenIdCredentials({
|
|
@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { glob as globTool } from "./glob.js";
|
|
5
5
|
import { grep as grepTool } from "./grep.js";
|
|
6
6
|
import { fileRead } from "./file-read.js";
|
|
7
|
+
import { buildScrubbedEnv } from "../../shared-tools/shell.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* FrontendAnalyze — 24 deterministic operations for the Jules Tanaka persona.
|
|
@@ -346,11 +347,22 @@ function checkErrorBoundaries(rootPath) {
|
|
|
346
347
|
};
|
|
347
348
|
}
|
|
348
349
|
|
|
350
|
+
// npm runs in the repository with credentials removed from its environment, so the repository's
|
|
351
|
+
// .npmrc cannot place them in a request.
|
|
352
|
+
function npmAuditInvocation(rootPath, env = process.env) {
|
|
353
|
+
return {
|
|
354
|
+
file: "npm",
|
|
355
|
+
args: ["audit", "--json", "--production"],
|
|
356
|
+
options: { cwd: rootPath, encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"], env: buildScrubbedEnv(env) },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export const __frontendAnalyzeForTests = Object.freeze({ npmAuditInvocation });
|
|
361
|
+
|
|
349
362
|
function auditNpmDeps(rootPath) {
|
|
350
363
|
try {
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
});
|
|
364
|
+
const invocation = npmAuditInvocation(rootPath);
|
|
365
|
+
const output = execFileSync(invocation.file, invocation.args, invocation.options);
|
|
354
366
|
const audit = JSON.parse(output);
|
|
355
367
|
return {
|
|
356
368
|
vulnerabilities: audit.metadata?.vulnerabilities || {},
|
|
@@ -3,7 +3,9 @@ import path from "node:path";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { credentialedRequest, isAuthenticated } from "../../../auth/credential-destinations.js";
|
|
6
7
|
import { assertPermittedAuditTarget } from "./url-policy.js";
|
|
8
|
+
import { buildScrubbedEnv } from "../../shared-tools/shell.js";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Jules Tanaka — Runtime Audit Tool
|
|
@@ -46,6 +48,37 @@ const RUNTIME_DISPATCH = {
|
|
|
46
48
|
check_dom_stats: checkDomStats,
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How Lighthouse runs. No shell: the target URL can come from the repository (package.json,
|
|
53
|
+
* vercel.json, .env files), and a shell would expand variables and operators inside it. Credentials
|
|
54
|
+
* are removed from npx's environment, so a repository .npmrc cannot place them in a request.
|
|
55
|
+
* On Windows, where npx is a .cmd file, npm's npx script runs under this Node.js instead.
|
|
56
|
+
*/
|
|
57
|
+
function lighthouseInvocation(
|
|
58
|
+
targetUrl,
|
|
59
|
+
outputPath,
|
|
60
|
+
{ platform = process.platform, execPath = process.execPath, exists = fs.existsSync, env = process.env } = {},
|
|
61
|
+
) {
|
|
62
|
+
const args = [
|
|
63
|
+
"--yes", "lighthouse@12", targetUrl,
|
|
64
|
+
"--output", "json", "--output-path", outputPath,
|
|
65
|
+
"--chrome-flags=--headless --no-sandbox --disable-gpu", "--quiet",
|
|
66
|
+
];
|
|
67
|
+
const options = {
|
|
68
|
+
encoding: "utf-8",
|
|
69
|
+
timeout: LIGHTHOUSE_TIMEOUT_MS,
|
|
70
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
71
|
+
env: buildScrubbedEnv(env),
|
|
72
|
+
};
|
|
73
|
+
if (platform !== "win32") {
|
|
74
|
+
return { file: "npx", args, options };
|
|
75
|
+
}
|
|
76
|
+
const npxCli = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js");
|
|
77
|
+
return exists(npxCli) ? { file: execPath, args: [npxCli, ...args], options } : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const __runtimeAuditForTests = Object.freeze({ lighthouseInvocation, detectDeployedUrl });
|
|
81
|
+
|
|
49
82
|
/**
|
|
50
83
|
* Run Lighthouse via npx (no install required).
|
|
51
84
|
* Returns performance, accessibility, best-practices, SEO scores + key metrics.
|
|
@@ -74,16 +107,11 @@ async function lighthouseScan(input) {
|
|
|
74
107
|
const outputDir = path.dirname(outputPath);
|
|
75
108
|
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
76
109
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
encoding: "utf-8",
|
|
83
|
-
timeout: LIGHTHOUSE_TIMEOUT_MS,
|
|
84
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
85
|
-
shell: true, // Windows: npx is npx.cmd, needs shell resolution
|
|
86
|
-
});
|
|
110
|
+
const invocation = lighthouseInvocation(targetUrl, outputPath);
|
|
111
|
+
if (!invocation) {
|
|
112
|
+
return { available: false, reason: "npx is not available next to this Node.js" };
|
|
113
|
+
}
|
|
114
|
+
execFileSync(invocation.file, invocation.args, invocation.options);
|
|
87
115
|
|
|
88
116
|
if (!fs.existsSync(outputPath)) {
|
|
89
117
|
return { available: false, reason: "Lighthouse produced no output" };
|
|
@@ -437,7 +465,7 @@ async function callScannerApi(url) {
|
|
|
437
465
|
});
|
|
438
466
|
} catch { /* session read failed */ }
|
|
439
467
|
|
|
440
|
-
if (!session
|
|
468
|
+
if (!isAuthenticated(session)) {
|
|
441
469
|
return { available: false, reason: "Not authenticated — run sl auth login" };
|
|
442
470
|
}
|
|
443
471
|
|
|
@@ -445,14 +473,16 @@ async function callScannerApi(url) {
|
|
|
445
473
|
const scanEndpoint = apiUrl + "/api/v1/scan/url";
|
|
446
474
|
|
|
447
475
|
// Submit scan
|
|
448
|
-
const submitResponse = await
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
476
|
+
const submitResponse = await credentialedRequest(
|
|
477
|
+
session.credential,
|
|
478
|
+
scanEndpoint,
|
|
479
|
+
{
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: { "Content-Type": "application/json" },
|
|
482
|
+
body: JSON.stringify({ url, scan_type: "lighthouse" }),
|
|
453
483
|
},
|
|
454
|
-
|
|
455
|
-
|
|
484
|
+
{ fetchImpl: (target, init) => fetchWithTimeout(target, init, 15000) },
|
|
485
|
+
);
|
|
456
486
|
|
|
457
487
|
if (!submitResponse.ok) {
|
|
458
488
|
return { available: false, reason: "Scanner API returned " + submitResponse.status };
|
|
@@ -469,9 +499,9 @@ async function callScannerApi(url) {
|
|
|
469
499
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
470
500
|
await new Promise(r => setTimeout(r, 3000));
|
|
471
501
|
try {
|
|
472
|
-
const pollResponse = await
|
|
473
|
-
|
|
474
|
-
}
|
|
502
|
+
const pollResponse = await credentialedRequest(session.credential, pollUrl, {}, {
|
|
503
|
+
fetchImpl: (target, init) => fetchWithTimeout(target, init, 10000),
|
|
504
|
+
});
|
|
475
505
|
if (!pollResponse.ok) continue;
|
|
476
506
|
const pollData = await pollResponse.json();
|
|
477
507
|
if (pollData.status === "completed" || pollData.status === "complete") {
|
package/src/ai/aidenid.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { authLoginHint } from "../ui/command-hints.js";
|
|
3
|
+
import { isAuthenticated } from "../auth/credential-destinations.js";
|
|
3
4
|
|
|
4
5
|
export const DEFAULT_AIDENID_API_URL = "https://api.aidenid.com";
|
|
5
6
|
|
|
@@ -179,14 +180,14 @@ async function resolveSessionCredentialContext({
|
|
|
179
180
|
env,
|
|
180
181
|
autoRotate: false,
|
|
181
182
|
});
|
|
182
|
-
if (activeSession
|
|
183
|
+
if (isAuthenticated(activeSession)) {
|
|
183
184
|
resolvedSession = activeSession;
|
|
184
185
|
}
|
|
185
|
-
if (resolvedSession &&
|
|
186
|
+
if (isAuthenticated(resolvedSession) && (!resolvedFetcher || isAuthenticated(activeSession))) {
|
|
186
187
|
resolvedFetcher = () =>
|
|
187
188
|
fetchAidenIdCredentials({
|
|
188
189
|
apiUrl: resolvedSession.apiUrl || "https://api.sentinelayer.com",
|
|
189
|
-
|
|
190
|
+
auth: resolvedSession,
|
|
190
191
|
});
|
|
191
192
|
}
|
|
192
193
|
} catch {
|
|
@@ -230,7 +231,7 @@ export async function resolveAidenIdCredentials(
|
|
|
230
231
|
const activeSession = sessionContext.session;
|
|
231
232
|
const activeFetchCredentials = sessionContext.fetchCredentials;
|
|
232
233
|
const sessionAidenId = activeSession && activeSession.aidenid ? activeSession.aidenid : null;
|
|
233
|
-
const hasSessionToken =
|
|
234
|
+
const hasSessionToken = isAuthenticated(activeSession);
|
|
234
235
|
|
|
235
236
|
let resolvedApiKey = String(apiKey || env.AIDENID_API_KEY || "").trim();
|
|
236
237
|
let resolvedOrgId = String(orgId || env.AIDENID_ORG_ID || (sessionAidenId && sessionAidenId.orgId) || "").trim();
|
package/src/ai/proxy.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Response: { content, usage: { model, provider, tokens_in, tokens_out, cost_usd, latency_ms } }
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { CredentialDestinationRefused, credentialedRequest, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
11
12
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
12
13
|
import { authLoginHint } from "../ui/command-hints.js";
|
|
13
14
|
|
|
@@ -318,7 +319,7 @@ function buildProxyError(status, parsed) {
|
|
|
318
319
|
* @param {number} [options.maxTokens] - Max output tokens (default: 4096)
|
|
319
320
|
* @param {number} [options.temperature] - Temperature (default: 0.1)
|
|
320
321
|
* @param {string} [options.apiUrl] - Override API URL
|
|
321
|
-
* @param {string} [options.token] -
|
|
322
|
+
* @param {string} [options.token] - The user's token, if the caller holds it: bound to the configured API
|
|
322
323
|
* @param {string} [options.sessionId] - Optional Senti session id for server-side usage metering
|
|
323
324
|
* @param {string} [options.agentId] - Optional session agent id for server-side usage metering
|
|
324
325
|
* @param {string} [options.action] - Optional metered action, defaults server-side when omitted
|
|
@@ -346,23 +347,24 @@ export async function invokeViaProxy({
|
|
|
346
347
|
metadata = null,
|
|
347
348
|
fetchImpl = fetch,
|
|
348
349
|
} = {}) {
|
|
349
|
-
// Resolve credentials from session if not provided
|
|
350
|
+
// Resolve credentials from session if not provided. A passed token is bound to the configured
|
|
351
|
+
// API (src/auth/credential-destinations.js), so an apiUrl on any other origin is refused.
|
|
350
352
|
let resolvedApiUrl = String(apiUrl || "").trim();
|
|
351
|
-
let
|
|
353
|
+
let credential = String(token || "").trim() ? await credentialFor({ token }) : null;
|
|
352
354
|
|
|
353
|
-
if (!resolvedApiUrl || !
|
|
355
|
+
if (!resolvedApiUrl || !credential) {
|
|
354
356
|
const session = await resolveActiveAuthSession({
|
|
355
357
|
cwd: process.cwd(),
|
|
356
358
|
env: process.env,
|
|
357
359
|
autoRotate: false,
|
|
358
360
|
});
|
|
359
|
-
if (!session
|
|
361
|
+
if (!isAuthenticated(session)) {
|
|
360
362
|
throw new Error(
|
|
361
363
|
`SentinelLayer LLM proxy requires authentication. Run '${authLoginHint()}' first.`
|
|
362
364
|
);
|
|
363
365
|
}
|
|
364
366
|
if (!resolvedApiUrl) resolvedApiUrl = String(session.apiUrl || "https://api.sentinelayer.com").trim();
|
|
365
|
-
if (!
|
|
367
|
+
if (!credential) credential = await credentialFor(session);
|
|
366
368
|
}
|
|
367
369
|
|
|
368
370
|
const url = `${resolvedApiUrl.replace(/\/+$/, "")}/api/v1/proxy/llm`;
|
|
@@ -393,7 +395,6 @@ export async function invokeViaProxy({
|
|
|
393
395
|
|
|
394
396
|
const headers = {
|
|
395
397
|
"Content-Type": "application/json",
|
|
396
|
-
Authorization: `Bearer ${resolvedToken}`,
|
|
397
398
|
Accept: "application/json",
|
|
398
399
|
};
|
|
399
400
|
if (normalizedUsageIdempotencyKey) {
|
|
@@ -410,12 +411,12 @@ export async function invokeViaProxy({
|
|
|
410
411
|
const controller = new AbortController();
|
|
411
412
|
const timeoutHandle = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
|
|
412
413
|
try {
|
|
413
|
-
response = await
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
body,
|
|
417
|
-
|
|
418
|
-
|
|
414
|
+
response = await credentialedRequest(
|
|
415
|
+
credential,
|
|
416
|
+
url,
|
|
417
|
+
{ method: "POST", headers, body, signal: controller.signal },
|
|
418
|
+
{ fetchImpl },
|
|
419
|
+
);
|
|
419
420
|
} finally {
|
|
420
421
|
clearTimeout(timeoutHandle);
|
|
421
422
|
}
|
|
@@ -436,7 +437,7 @@ export async function invokeViaProxy({
|
|
|
436
437
|
|
|
437
438
|
throw buildProxyError(response.status, parsedError);
|
|
438
439
|
} catch (err) {
|
|
439
|
-
if (err instanceof SentinelayerProxyError) {
|
|
440
|
+
if (err instanceof SentinelayerProxyError || err instanceof CredentialDestinationRefused) {
|
|
440
441
|
throw err;
|
|
441
442
|
}
|
|
442
443
|
lastError = err;
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { userCredential } from "../auth/credential-destinations.js";
|
|
5
6
|
import { resolveOutputRoot } from "../config/service.js";
|
|
6
7
|
import { createAgentEvent } from "../events/schema.js";
|
|
7
8
|
import { resolveCodebaseIngest } from "../ingest/engine.js";
|
|
@@ -566,11 +567,12 @@ export async function runAuditOrchestrator({
|
|
|
566
567
|
}
|
|
567
568
|
const memoryProvider = resolveMemoryProvider(process.env);
|
|
568
569
|
const memoryApiEndpoint = normalizeString(process.env.SENTINELAYER_MEMORY_API_ENDPOINT);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
570
|
+
// The memory service's own key, if configured; otherwise the user's token as a credential, which
|
|
571
|
+
// reaches the memory endpoint only if it is on the configured API's origin.
|
|
572
|
+
const memoryApiKey = normalizeString(process.env.SENTINELAYER_MEMORY_API_KEY);
|
|
573
|
+
const memoryCredential = memoryApiKey
|
|
574
|
+
? null
|
|
575
|
+
: await userCredential(process.env.SENTINELAYER_TOKEN || process.env.SENTINELAYER_API_TOKEN, { source: "env" });
|
|
574
576
|
const sharedMemoryCorpus = await buildSharedMemoryCorpus({
|
|
575
577
|
outputRoot,
|
|
576
578
|
targetPath: normalizedTargetPath,
|
|
@@ -628,6 +630,7 @@ export async function runAuditOrchestrator({
|
|
|
628
630
|
provider: memoryProvider,
|
|
629
631
|
apiEndpoint: memoryApiEndpoint,
|
|
630
632
|
apiKey: memoryApiKey,
|
|
633
|
+
credential: memoryCredential,
|
|
631
634
|
});
|
|
632
635
|
sharedMemoryQueries.push({
|
|
633
636
|
agentId: agent.id,
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
// from inside the scope, so no human-authenticated subrequest can hide behind a
|
|
9
9
|
// scoped main request.
|
|
10
10
|
//
|
|
11
|
-
// It imports
|
|
12
|
-
//
|
|
11
|
+
// It imports only the credential module, so the auth service can depend on it without an
|
|
12
|
+
// import cycle.
|
|
13
13
|
|
|
14
14
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
15
|
|
|
16
|
+
import { admissionCredential } from "./credential-destinations.js";
|
|
17
|
+
|
|
16
18
|
export const admittedAgentScope = new AsyncLocalStorage();
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -26,7 +28,7 @@ export function scopedAdmissionAuth() {
|
|
|
26
28
|
if (!(credential.expiresAt * 1000 > Date.now())) return null;
|
|
27
29
|
return {
|
|
28
30
|
apiUrl: credential.apiUrl,
|
|
29
|
-
|
|
31
|
+
credential: admissionCredential(credential), // bound to the API that issued it; its token stays inside
|
|
30
32
|
source: "session_admission",
|
|
31
33
|
user: null,
|
|
32
34
|
admissionId: credential.admissionId,
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// credential-destinations.js — the user's credentials, and the one way to send them.
|
|
2
|
+
//
|
|
3
|
+
// A credential is an opaque object, { origin, source }, and only this module makes one. The
|
|
4
|
+
// token it carries is held here, out of reach: it is not a property of the credential or of any
|
|
5
|
+
// auth result, so code elsewhere cannot read it, copy it into a header, a URL, a body or a
|
|
6
|
+
// cookie, or hand it to a child process. credentialedRequest is the one thing that uses it.
|
|
7
|
+
//
|
|
8
|
+
// Its origin is fixed when it is minted, from ONE trust context (resolveTrustContext) built from
|
|
9
|
+
// the environment and the user's global config:
|
|
10
|
+
//
|
|
11
|
+
// - the configured API: SENTINELAYER_API_URL, else `apiUrl` in ~/.sentinelayer/config.yml,
|
|
12
|
+
// else the default API
|
|
13
|
+
// - the pocket gateway: SENTI_POCKET_URL, when set (POCKET_GATEWAY_URL is not read)
|
|
14
|
+
//
|
|
15
|
+
// The CLI entry resolves that context once, at start, and freezes it (freezeProcessTrustContext),
|
|
16
|
+
// so nothing that changes process.env later can move it. Login, auth resolution and the request
|
|
17
|
+
// check all use it; tests inject their own env and homeDir. Nothing a command is given (an
|
|
18
|
+
// option, an argument, an MCP tool input, a workspace's .sentinelayer.yml) can change where a
|
|
19
|
+
// credential goes. An agent's admission credential is bound to the API that issued it
|
|
20
|
+
// (src/auth/admission-scope.js) and is never re-bound to the pocket gateway.
|
|
21
|
+
//
|
|
22
|
+
// credentialedRequest(credential, url, init, { fetchImpl }) is the one way to send a credential.
|
|
23
|
+
// It refuses a URL on any other origin, sets the Authorization header itself, and never follows
|
|
24
|
+
// a redirect. An injected transport (fetchImpl) is what it calls once those checks pass. A bare
|
|
25
|
+
// token from an injected auth resolver (tests) is bound to the configured API by credentialFor.
|
|
26
|
+
// tests/unit.credential-census.test.mjs fails on any other place in src/ that builds an
|
|
27
|
+
// Authorization header, reads a raw token, or writes a trust-source variable into process.env.
|
|
28
|
+
|
|
29
|
+
import { createHmac } from "node:crypto";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import process from "node:process";
|
|
32
|
+
|
|
33
|
+
import { readConfigFile } from "../config/io.js";
|
|
34
|
+
import { getGlobalConfigPath } from "../config/paths.js";
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_API_URL = "https://api.sentinelayer.com";
|
|
37
|
+
|
|
38
|
+
const tokens = new WeakMap(); // credential -> token; the only place a token is kept
|
|
39
|
+
const contexts = new WeakSet(); // trust contexts this module built
|
|
40
|
+
const USER_SOURCES = new Set(["user", "env", "config", "session"]); // the person's own token
|
|
41
|
+
let processContext = null; // frozen at the CLI entry
|
|
42
|
+
|
|
43
|
+
export class CredentialDestinationRefused extends Error {
|
|
44
|
+
constructor(origin, trusted = []) {
|
|
45
|
+
super(
|
|
46
|
+
`Refusing to send your SentinelLayer credential to ${origin}: it is only sent to ` +
|
|
47
|
+
`${trusted.filter(Boolean).join(", ") || "the configured API"}. Custom API origins must be configured: set ` +
|
|
48
|
+
`SENTINELAYER_API_URL or SENTI_POCKET_URL (or \`apiUrl\` in ~/.sentinelayer/config.yml).`,
|
|
49
|
+
);
|
|
50
|
+
this.name = "CredentialDestinationRefused";
|
|
51
|
+
this.code = "CREDENTIAL_DESTINATION_REFUSED";
|
|
52
|
+
this.origin = origin;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function originOf(value) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(String(value || "").trim());
|
|
59
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : "";
|
|
60
|
+
} catch {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mint(token, origin, source) {
|
|
66
|
+
const value = String(token ?? "").trim();
|
|
67
|
+
if (!value || !origin) return null;
|
|
68
|
+
const credential = Object.freeze({ origin, source });
|
|
69
|
+
tokens.set(credential, value);
|
|
70
|
+
return credential;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether a value is a credential this module minted. */
|
|
74
|
+
export function isCredential(value) {
|
|
75
|
+
return tokens.has(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether an auth result carries a credential (or, from an injected resolver, a bare token). */
|
|
79
|
+
export function isAuthenticated(auth) {
|
|
80
|
+
return isCredential(auth?.credential) || Boolean(String(auth?.token ?? "").trim());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function buildTrustContext({ env, homeDir }) {
|
|
84
|
+
let apiUrl = String(env.SENTINELAYER_API_URL || "").trim();
|
|
85
|
+
if (!apiUrl) {
|
|
86
|
+
const globalConfig = await readConfigFile(getGlobalConfigPath({ homeDir })).catch(() => ({}));
|
|
87
|
+
apiUrl = String(globalConfig?.apiUrl || "").trim() || DEFAULT_API_URL;
|
|
88
|
+
}
|
|
89
|
+
const context = Object.freeze({
|
|
90
|
+
apiOrigin: originOf(apiUrl),
|
|
91
|
+
gatewayOrigin: originOf(env.SENTI_POCKET_URL),
|
|
92
|
+
});
|
|
93
|
+
contexts.add(context);
|
|
94
|
+
return context;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the process trust context once, from the real environment and global config, and
|
|
99
|
+
* freeze it: from then on, process.env changes do not move it. Called first by the CLI entry.
|
|
100
|
+
*/
|
|
101
|
+
export async function freezeProcessTrustContext() {
|
|
102
|
+
if (!processContext) {
|
|
103
|
+
const env = { SENTINELAYER_API_URL: process.env.SENTINELAYER_API_URL, SENTI_POCKET_URL: process.env.SENTI_POCKET_URL };
|
|
104
|
+
processContext = await buildTrustContext({ env, homeDir: os.homedir() });
|
|
105
|
+
}
|
|
106
|
+
return processContext;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* THE trust context: where the user's credentials may be sent. Built from the environment and
|
|
111
|
+
* the global config only. For the process environment, once frozen, it is the frozen context.
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveTrustContext({ env = process.env, homeDir } = {}) {
|
|
114
|
+
if (processContext && env === process.env && homeDir === undefined) return processContext;
|
|
115
|
+
return buildTrustContext({ env, homeDir });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function trustOf({ context, env, homeDir }) {
|
|
119
|
+
if (context === undefined) return resolveTrustContext({ env, homeDir });
|
|
120
|
+
if (!contexts.has(context)) throw new TypeError("A trust context from resolveTrustContext is required.");
|
|
121
|
+
return context;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A credential for one of the user's tokens, bound to the configured API. */
|
|
125
|
+
export async function userCredential(token, { context, env, homeDir, source = "user" } = {}) {
|
|
126
|
+
return mint(token, (await trustOf({ context, env, homeDir })).apiOrigin, source);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The person's own token bound to the configured pocket gateway; null when none is configured.
|
|
131
|
+
* An agent's admission credential, or any token minted for one purpose, is never re-bound.
|
|
132
|
+
*/
|
|
133
|
+
export async function gatewayCredential(credential, { context, env, homeDir } = {}) {
|
|
134
|
+
if (!isCredential(credential)) throw new TypeError("gatewayCredential needs a credential.");
|
|
135
|
+
const trust = await trustOf({ context, env, homeDir });
|
|
136
|
+
if (!USER_SOURCES.has(credential.source)) {
|
|
137
|
+
throw new CredentialDestinationRefused(trust.gatewayOrigin || "the pocket gateway", [credential.origin]);
|
|
138
|
+
}
|
|
139
|
+
return mint(tokens.get(credential), trust.gatewayOrigin, "pocket_gateway");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** An agent's admission credential, bound to the API that issued it. */
|
|
143
|
+
export function admissionCredential({ token, apiUrl } = {}) {
|
|
144
|
+
return mint(token, originOf(apiUrl), "session_admission");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The credential for an auth result: its own, or, for a bare token from an injected resolver,
|
|
149
|
+
* the token bound to the configured API. Never bound to an origin the caller supplies.
|
|
150
|
+
*/
|
|
151
|
+
export async function credentialFor(auth, { context, env, homeDir } = {}) {
|
|
152
|
+
if (isCredential(auth?.credential)) return auth.credential;
|
|
153
|
+
if (!String(auth?.token ?? "").trim()) return null;
|
|
154
|
+
return userCredential(auth.token, { context, env, homeDir, source: auth.source || "user" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Refuse an API URL outside the trust context, before any credential is minted for it. */
|
|
158
|
+
export async function assertTrustedApiUrl(url, { context, env, homeDir } = {}) {
|
|
159
|
+
const trust = await trustOf({ context, env, homeDir });
|
|
160
|
+
const origin = originOf(url) || String(url);
|
|
161
|
+
if (origin !== trust.apiOrigin) throw new CredentialDestinationRefused(origin, [trust.apiOrigin]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Refuse sending `credential` anywhere but its own origin. `target` is the exact URL string sent. */
|
|
165
|
+
export function assertCredentialDestination(credential, target) {
|
|
166
|
+
if (!isCredential(credential)) throw new TypeError("A credential from src/auth/credential-destinations.js is required.");
|
|
167
|
+
const origin = originOf(target);
|
|
168
|
+
if (origin !== credential.origin) throw new CredentialDestinationRefused(origin || String(target), [credential.origin]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* An injectable request function (requestJson-shaped: (url, options)), checked first: a
|
|
173
|
+
* credential in its options goes nowhere but its own origin, whichever implementation runs.
|
|
174
|
+
*/
|
|
175
|
+
export function checkedTransport(requestImpl) {
|
|
176
|
+
return async (url, options, ...rest) => {
|
|
177
|
+
const target = String(url); // checked and passed on as the same string
|
|
178
|
+
if (options?.credential) assertCredentialDestination(options.credential, target);
|
|
179
|
+
return requestImpl(target, options, ...rest);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function headersWithout(headers, name) {
|
|
184
|
+
const entries = headers instanceof Headers ? [...headers.entries()] : Object.entries(headers || {});
|
|
185
|
+
return Object.fromEntries(entries.filter(([key]) => String(key).toLowerCase() !== name));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Send a request carrying `credential`: only to the credential's own origin, with the
|
|
190
|
+
* Authorization header set here, and without following redirects. `fetchImpl` is the transport
|
|
191
|
+
* it calls once those checks pass. The URL is turned into a string once, and that string is both
|
|
192
|
+
* checked and sent.
|
|
193
|
+
*/
|
|
194
|
+
export async function credentialedRequest(credential, url, init = {}, { fetchImpl = globalThis.fetch } = {}) {
|
|
195
|
+
const target = String(url);
|
|
196
|
+
assertCredentialDestination(credential, target);
|
|
197
|
+
return fetchImpl(target, {
|
|
198
|
+
...init,
|
|
199
|
+
headers: { ...headersWithout(init.headers, "authorization"), Authorization: `Bearer ${tokens.get(credential)}` },
|
|
200
|
+
redirect: "error",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** An HMAC-SHA256 of `message` keyed by the credential's token (a request proof; never the token). */
|
|
205
|
+
export function credentialHmac(credential, message) {
|
|
206
|
+
if (!isCredential(credential)) throw new TypeError("A credential from src/auth/credential-destinations.js is required.");
|
|
207
|
+
return createHmac("sha256", tokens.get(credential)).update(String(message), "utf8").digest("hex");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The token itself, for the one reviewed export: the operator command `sl scan setup-secrets`,
|
|
212
|
+
* which writes it to a GitHub Actions secret through the gh CLI. Nothing else may call this
|
|
213
|
+
* (tests/unit.credential-census.test.mjs).
|
|
214
|
+
*/
|
|
215
|
+
export function exportCredentialToken(credential, { purpose } = {}) {
|
|
216
|
+
if (purpose !== "github-actions-secret") throw new TypeError("exportCredentialToken: unknown purpose.");
|
|
217
|
+
if (!isCredential(credential) || !USER_SOURCES.has(credential.source)) {
|
|
218
|
+
throw new TypeError("exportCredentialToken needs the person's own credential.");
|
|
219
|
+
}
|
|
220
|
+
return tokens.get(credential);
|
|
221
|
+
}
|