sentinelayer-cli 0.46.0 → 0.46.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-sentinelayer.js +2 -2
- package/bin/sentinelayer-cli.js +2 -2
- package/bin/sl.js +2 -2
- package/package.json +1 -1
- package/src/agents/agent-id-path.js +57 -0
- package/src/agents/jules/tools/runtime-audit.js +2 -1
- package/src/ai/aidenid.js +11 -10
- package/src/ai/client.js +2 -1
- package/src/audit/orchestrator.js +7 -1
- package/src/audit/registry.js +9 -1
- package/src/auth/credential-destinations.js +57 -13
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +4 -3
- package/src/board/evidence-resolvers.js +15 -3
- package/src/cli-bootstrap.js +20 -0
- package/src/cli.js +3 -1
- package/src/commands/ai/identity-lifecycle.js +4 -3
- package/src/commands/chat.js +4 -1
- package/src/commands/session.js +51 -17
- package/src/legacy-cli.js +4 -11
- package/src/mcp/session-stdio-server.js +13 -9
- package/src/net/gh-api-path.js +68 -0
- package/src/net/url-path.js +41 -0
- package/src/review/dd-report-email-client.js +2 -3
- package/src/scan/gh-secrets.js +4 -1
- package/src/session/admission-access.js +17 -8
- package/src/session/admission.js +12 -5
- package/src/session/agent-registry.js +4 -5
- package/src/session/checkpoints.js +4 -3
- package/src/session/file-locks.js +8 -6
- package/src/session/invitations.js +2 -1
- package/src/session/listener-process.js +118 -32
- package/src/session/live-source.js +2 -3
- package/src/session/paths.js +51 -2
- package/src/session/store.js +3 -3
- package/src/session/sync.js +34 -26
- package/src/session/tickets.js +13 -6
- package/src/session/title-sync.js +2 -1
package/bin/sentinelayer-cli.js
CHANGED
package/bin/sl.js
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
// An agent id names exactly one entry directly under the directory that holds per-agent files:
|
|
4
|
+
// <run>/agents/<id>.json for an audit run, <session>/agents/<id>.json for a session.
|
|
5
|
+
//
|
|
6
|
+
// The rule is the canonical agent id (canonicalAgentId in src/session/admission-auth.js:
|
|
7
|
+
// lowercase letters, digits, '.', '_' and '-', never starting or ending with '-'), held to a
|
|
8
|
+
// spelling that is also one portable file name:
|
|
9
|
+
// - 1-64 characters, the bound admission already puts on the same ids;
|
|
10
|
+
// - starts with a letter or digit, so it is never '.' or '..';
|
|
11
|
+
// - does not end with '.': Windows drops it, so "abc." and "abc" would name the same file;
|
|
12
|
+
// - is not a Windows device name (refused on every platform).
|
|
13
|
+
// Every id in use fits: built-in audit agents and personas (security, code-quality,
|
|
14
|
+
// ai-governance), ids from --agent (canonicalised before they get here), generated ids
|
|
15
|
+
// (claude-a1b2, claude-3) and daemon ids (senti, scope-engine, audit-orchestrator).
|
|
16
|
+
const AGENT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9_])?$/;
|
|
17
|
+
// CON, PRN, AUX, NUL, COM1-9 and LPT1-9, with or without an extension.
|
|
18
|
+
const RESERVED_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/;
|
|
19
|
+
// Either separator, on every platform.
|
|
20
|
+
const SEPARATOR = /[\\/]/;
|
|
21
|
+
|
|
22
|
+
export const AGENT_ID_RULE =
|
|
23
|
+
"agent id must be 1-64 characters: lowercase letters, digits, '.', '_' or '-', starting with a letter or digit and ending with a letter, digit or '_'.";
|
|
24
|
+
export const AGENT_ID_RESERVED_RULE = "agent id must not be a reserved device name.";
|
|
25
|
+
export const AGENT_ID_ENTRY_RULE = "agent id must name one entry directly under its directory.";
|
|
26
|
+
|
|
27
|
+
/** The rule `value` breaks, or "" when it is an agent id that can name a file. */
|
|
28
|
+
export function agentIdProblem(value) {
|
|
29
|
+
const id = typeof value === "string" ? value : "";
|
|
30
|
+
if (!AGENT_ID_PATTERN.test(id)) return AGENT_ID_RULE;
|
|
31
|
+
if (RESERVED_DEVICE_NAME.test(id)) return AGENT_ID_RESERVED_RULE;
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `<dir>/<agentId><suffix>`, refused unless the result is exactly one entry directly under
|
|
37
|
+
* `dir` and the id follows the agent id rule. The one-entry check runs first, on the id as
|
|
38
|
+
* given (no separator, and the resolved path is a direct child of `dir` with that exact name),
|
|
39
|
+
* so it holds by itself whatever the rule admits.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveAgentIdPath(dir, agentId, suffix = "") {
|
|
42
|
+
const root = path.resolve(String(dir || "."));
|
|
43
|
+
const id = typeof agentId === "string" ? agentId : String(agentId ?? "");
|
|
44
|
+
if (!id) {
|
|
45
|
+
throw new Error("agentId is required.");
|
|
46
|
+
}
|
|
47
|
+
const entry = `${id}${suffix}`;
|
|
48
|
+
const target = path.resolve(root, entry);
|
|
49
|
+
if (SEPARATOR.test(entry) || path.dirname(target) !== root || path.basename(target) !== entry) {
|
|
50
|
+
throw new Error(AGENT_ID_ENTRY_RULE);
|
|
51
|
+
}
|
|
52
|
+
const problem = agentIdProblem(id);
|
|
53
|
+
if (problem) {
|
|
54
|
+
throw new Error(problem);
|
|
55
|
+
}
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { credentialedRequest, isAuthenticated } from "../../../auth/credential-destinations.js";
|
|
7
|
+
import { urlPathSegment } from "../../../net/url-path.js";
|
|
7
8
|
import { assertPermittedAuditTarget } from "./url-policy.js";
|
|
8
9
|
import { buildScrubbedEnv } from "../../shared-tools/shell.js";
|
|
9
10
|
|
|
@@ -495,7 +496,7 @@ async function callScannerApi(url) {
|
|
|
495
496
|
}
|
|
496
497
|
|
|
497
498
|
// Poll for completion (max 90s)
|
|
498
|
-
const pollUrl = apiUrl
|
|
499
|
+
const pollUrl = `${apiUrl}/api/v1/scan/url/${urlPathSegment(scanId, { label: "scanId" })}`;
|
|
499
500
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
500
501
|
await new Promise(r => setTimeout(r, 3000));
|
|
501
502
|
try {
|
package/src/ai/aidenid.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { authLoginHint } from "../ui/command-hints.js";
|
|
3
3
|
import { isAuthenticated } from "../auth/credential-destinations.js";
|
|
4
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_AIDENID_API_URL = "https://api.aidenid.com";
|
|
6
7
|
|
|
@@ -439,7 +440,7 @@ export async function revokeIdentity({
|
|
|
439
440
|
});
|
|
440
441
|
|
|
441
442
|
const response = await fetchImpl(
|
|
442
|
-
`${normalizedApiUrl}/v1/identities/${
|
|
443
|
+
`${normalizedApiUrl}/v1/identities/${urlPathSegment(normalizedIdentityId, { label: "identityId" })}/revoke`,
|
|
443
444
|
{
|
|
444
445
|
method: "POST",
|
|
445
446
|
headers: requestHeaders,
|
|
@@ -482,7 +483,7 @@ export async function listIdentityEvents({
|
|
|
482
483
|
const requestHeaders = buildReadHeaders({ apiKey, orgId, projectId });
|
|
483
484
|
|
|
484
485
|
const endpoint = new URL(
|
|
485
|
-
`${normalizedApiUrl}/v1/identities/${
|
|
486
|
+
`${normalizedApiUrl}/v1/identities/${urlPathSegment(normalizedIdentityId, { label: "identityId" })}/events`
|
|
486
487
|
);
|
|
487
488
|
endpoint.searchParams.set("limit", String(normalizedLimit));
|
|
488
489
|
if (normalizedCursor) {
|
|
@@ -530,7 +531,7 @@ export async function getLatestIdentityExtraction({
|
|
|
530
531
|
const requestHeaders = buildReadHeaders({ apiKey, orgId, projectId });
|
|
531
532
|
|
|
532
533
|
const response = await fetchImpl(
|
|
533
|
-
`${normalizedApiUrl}/v1/identities/${
|
|
534
|
+
`${normalizedApiUrl}/v1/identities/${urlPathSegment(normalizedIdentityId, { label: "identityId" })}/latest-extraction`,
|
|
534
535
|
{
|
|
535
536
|
method: "GET",
|
|
536
537
|
headers: requestHeaders,
|
|
@@ -595,7 +596,7 @@ export async function createChildIdentity({
|
|
|
595
596
|
});
|
|
596
597
|
|
|
597
598
|
const response = await fetchImpl(
|
|
598
|
-
`${normalizedApiUrl}/v1/identities/${
|
|
599
|
+
`${normalizedApiUrl}/v1/identities/${urlPathSegment(normalizedParentIdentityId, { label: "identityId" })}/children`,
|
|
599
600
|
{
|
|
600
601
|
method: "POST",
|
|
601
602
|
headers: requestHeaders,
|
|
@@ -635,7 +636,7 @@ export async function getIdentityLineage({
|
|
|
635
636
|
const requestHeaders = buildReadHeaders({ apiKey, orgId, projectId });
|
|
636
637
|
|
|
637
638
|
const response = await fetchImpl(
|
|
638
|
-
`${normalizedApiUrl}/v1/identities/${
|
|
639
|
+
`${normalizedApiUrl}/v1/identities/${urlPathSegment(normalizedIdentityId, { label: "identityId" })}/lineage`,
|
|
639
640
|
{
|
|
640
641
|
method: "GET",
|
|
641
642
|
headers: requestHeaders,
|
|
@@ -686,7 +687,7 @@ export async function revokeIdentityChildren({
|
|
|
686
687
|
});
|
|
687
688
|
|
|
688
689
|
const response = await fetchImpl(
|
|
689
|
-
`${normalizedApiUrl}/v1/identities/${
|
|
690
|
+
`${normalizedApiUrl}/v1/identities/${urlPathSegment(normalizedIdentityId, { label: "identityId" })}/revoke-children`,
|
|
690
691
|
{
|
|
691
692
|
method: "POST",
|
|
692
693
|
headers: requestHeaders,
|
|
@@ -781,7 +782,7 @@ export async function verifyDomain({
|
|
|
781
782
|
});
|
|
782
783
|
|
|
783
784
|
const response = await fetchImpl(
|
|
784
|
-
`${normalizedApiUrl}/v1/domains/${
|
|
785
|
+
`${normalizedApiUrl}/v1/domains/${urlPathSegment(normalizedDomainId, { label: "domainId" })}/verify`,
|
|
785
786
|
{
|
|
786
787
|
method: "POST",
|
|
787
788
|
headers: requestHeaders,
|
|
@@ -828,7 +829,7 @@ export async function freezeDomain({
|
|
|
828
829
|
});
|
|
829
830
|
|
|
830
831
|
const response = await fetchImpl(
|
|
831
|
-
`${normalizedApiUrl}/v1/domains/${
|
|
832
|
+
`${normalizedApiUrl}/v1/domains/${urlPathSegment(normalizedDomainId, { label: "domainId" })}/freeze`,
|
|
832
833
|
{
|
|
833
834
|
method: "POST",
|
|
834
835
|
headers: requestHeaders,
|
|
@@ -917,7 +918,7 @@ export async function verifyTarget({
|
|
|
917
918
|
});
|
|
918
919
|
|
|
919
920
|
const response = await fetchImpl(
|
|
920
|
-
`${normalizedApiUrl}/v1/targets/${
|
|
921
|
+
`${normalizedApiUrl}/v1/targets/${urlPathSegment(normalizedTargetId, { label: "targetId" })}/verify`,
|
|
921
922
|
{
|
|
922
923
|
method: "POST",
|
|
923
924
|
headers: requestHeaders,
|
|
@@ -957,7 +958,7 @@ export async function getTarget({
|
|
|
957
958
|
const requestHeaders = buildReadHeaders({ apiKey, orgId, projectId });
|
|
958
959
|
|
|
959
960
|
const response = await fetchImpl(
|
|
960
|
-
`${normalizedApiUrl}/v1/targets/${
|
|
961
|
+
`${normalizedApiUrl}/v1/targets/${urlPathSegment(normalizedTargetId, { label: "targetId" })}`,
|
|
961
962
|
{
|
|
962
963
|
method: "GET",
|
|
963
964
|
headers: requestHeaders,
|
package/src/ai/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { invokeViaProxy, DEFAULT_PROXY_MODEL } from "./proxy.js";
|
|
2
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
2
3
|
|
|
3
4
|
const PROVIDER_ENV_KEYS = Object.freeze({
|
|
4
5
|
openai: "OPENAI_API_KEY",
|
|
@@ -248,7 +249,7 @@ function buildProviderRequest({ provider, apiKey, model, prompt, stream }) {
|
|
|
248
249
|
|
|
249
250
|
const method = stream ? "streamGenerateContent" : "generateContent";
|
|
250
251
|
return {
|
|
251
|
-
url: `https://generativelanguage.googleapis.com/v1beta/models/${
|
|
252
|
+
url: `https://generativelanguage.googleapis.com/v1beta/models/${urlPathSegment(model, { label: "model" })}:${method}`,
|
|
252
253
|
headers: {
|
|
253
254
|
"Content-Type": "application/json",
|
|
254
255
|
"x-goog-api-key": apiKey,
|
|
@@ -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 { resolveAgentIdPath } from "../agents/agent-id-path.js";
|
|
5
6
|
import { userCredential } from "../auth/credential-destinations.js";
|
|
6
7
|
import { resolveOutputRoot } from "../config/service.js";
|
|
7
8
|
import { createAgentEvent } from "../events/schema.js";
|
|
@@ -396,6 +397,11 @@ export async function runAuditOrchestrator({
|
|
|
396
397
|
const startedAt = Date.now();
|
|
397
398
|
const runDirectory = path.join(outputRoot, "audits", runId);
|
|
398
399
|
const agentsDirectory = path.join(runDirectory, "agents");
|
|
400
|
+
// Each agent's file is resolved before anything is written: an id that is not one file
|
|
401
|
+
// directly under the agents directory stops the run here.
|
|
402
|
+
const agentPaths = new Map(
|
|
403
|
+
agents.map((agent) => [agent, resolveAgentIdPath(agentsDirectory, agent.id, ".json")])
|
|
404
|
+
);
|
|
399
405
|
await fsp.mkdir(agentsDirectory, { recursive: true });
|
|
400
406
|
const blackboard = createBlackboard({
|
|
401
407
|
runId,
|
|
@@ -754,7 +760,7 @@ export async function runAuditOrchestrator({
|
|
|
754
760
|
note: `${agent.id} persona finding`,
|
|
755
761
|
confidence,
|
|
756
762
|
});
|
|
757
|
-
const agentPath =
|
|
763
|
+
const agentPath = agentPaths.get(agent);
|
|
758
764
|
await fsp.writeFile(agentPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
|
|
759
765
|
emitAuditLifecycleEvent(
|
|
760
766
|
onEvent,
|
package/src/audit/registry.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { agentIdProblem } from "../agents/agent-id-path.js";
|
|
5
|
+
|
|
4
6
|
export const DEFAULT_AUDIT_AGENT_TOOLS = Object.freeze([
|
|
5
7
|
"FileRead",
|
|
6
8
|
"Grep",
|
|
@@ -246,11 +248,17 @@ function mergeRegistry(builtinAgents = [], overrideAgents = []) {
|
|
|
246
248
|
for (const builtin of builtinAgents) {
|
|
247
249
|
byId.set(builtin.id, { ...builtin });
|
|
248
250
|
}
|
|
249
|
-
for (const override of overrideAgents) {
|
|
251
|
+
for (const [index, override] of overrideAgents.entries()) {
|
|
250
252
|
const normalized = normalizeAgentRecord(override);
|
|
251
253
|
if (!normalized.id) {
|
|
252
254
|
continue;
|
|
253
255
|
}
|
|
256
|
+
// An agent id names the agent's file in every audit run, so the whole registry is refused
|
|
257
|
+
// before anything is written when one id cannot.
|
|
258
|
+
const problem = agentIdProblem(normalized.id);
|
|
259
|
+
if (problem) {
|
|
260
|
+
throw new Error(`Invalid audit registry file: agents[${index}].id ${JSON.stringify(normalized.id)}: ${problem}`);
|
|
261
|
+
}
|
|
254
262
|
const existing = byId.get(normalized.id) || {};
|
|
255
263
|
if (!Object.prototype.hasOwnProperty.call(override, "tools")) {
|
|
256
264
|
normalized.tools = Array.isArray(existing.tools) && existing.tools.length > 0
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
// (src/auth/admission-scope.js) and is never re-bound to the pocket gateway.
|
|
21
21
|
//
|
|
22
22
|
// credentialedRequest(credential, url, init, { fetchImpl }) is the one way to send a credential.
|
|
23
|
-
// It
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// It takes the URL as a string, refuses one on any other origin or one whose path is not plain
|
|
24
|
+
// segments, sets the Authorization header itself, and never follows a redirect. An injected
|
|
25
|
+
// transport (fetchImpl) is what it calls once those checks pass. A bare token from an injected
|
|
26
|
+
// auth resolver (tests) is bound to the configured API by credentialFor.
|
|
26
27
|
// tests/unit.credential-census.test.mjs fails on any other place in src/ that builds an
|
|
27
28
|
// Authorization header, reads a raw token, or writes a trust-source variable into process.env.
|
|
28
29
|
|
|
@@ -53,6 +54,45 @@ export class CredentialDestinationRefused extends Error {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A URL on the credential's own origin whose path is not plain segments: a segment that is, or
|
|
59
|
+
* decodes to, "." or "..", that decodes to something containing "/" or "\", or that does not
|
|
60
|
+
* decode. Nothing is sent.
|
|
61
|
+
*/
|
|
62
|
+
export class CredentialPathRefused extends CredentialDestinationRefused {
|
|
63
|
+
constructor(origin) {
|
|
64
|
+
super(origin);
|
|
65
|
+
this.message =
|
|
66
|
+
"Refusing to send your SentinelLayer credential to a URL whose path is not plain segments: " +
|
|
67
|
+
"each identifier in a URL path must be one segment.";
|
|
68
|
+
this.name = "CredentialPathRefused";
|
|
69
|
+
this.code = "CREDENTIAL_PATH_REFUSED";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One raw path segment, decoded once: refused when it is "." or "..", holds "/" or "\", or does not decode. */
|
|
74
|
+
function isUnsafePathSegment(segment) {
|
|
75
|
+
let decoded;
|
|
76
|
+
try {
|
|
77
|
+
decoded = decodeURIComponent(segment);
|
|
78
|
+
} catch {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return decoded === "." || decoded === ".." || /[/\\]/.test(decoded);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether the raw URL string has a path segment that is not plain, read the way the URL parser
|
|
86
|
+
* reads an http(s) URL, before it normalizes anything: tabs and newlines dropped, leading and
|
|
87
|
+
* trailing C0 controls and spaces trimmed, the path after the scheme and authority up to "?" or
|
|
88
|
+
* "#", and "\" a separator like "/". Each segment is then decoded once (isUnsafePathSegment).
|
|
89
|
+
*/
|
|
90
|
+
function hasUnsafePathSegment(target) {
|
|
91
|
+
const text = target.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, "");
|
|
92
|
+
const afterAuthority = text.replace(/^[a-z][a-z0-9+.-]*:[/\\]*[^/\\?#]*/i, "");
|
|
93
|
+
return afterAuthority.split(/[?#]/, 1)[0].split(/[/\\]/).some(isUnsafePathSegment);
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
function originOf(value) {
|
|
57
97
|
try {
|
|
58
98
|
const parsed = new URL(String(value || "").trim());
|
|
@@ -161,11 +201,17 @@ export async function assertTrustedApiUrl(url, { context, env, homeDir } = {}) {
|
|
|
161
201
|
if (origin !== trust.apiOrigin) throw new CredentialDestinationRefused(origin, [trust.apiOrigin]);
|
|
162
202
|
}
|
|
163
203
|
|
|
164
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Refuse sending `credential` anywhere but its own origin, or to a path that is not plain
|
|
206
|
+
* segments. `target` is the exact URL string sent; a URL object is refused, because its path has
|
|
207
|
+
* already been normalized and the string that was built can no longer be checked.
|
|
208
|
+
*/
|
|
165
209
|
export function assertCredentialDestination(credential, target) {
|
|
166
210
|
if (!isCredential(credential)) throw new TypeError("A credential from src/auth/credential-destinations.js is required.");
|
|
211
|
+
if (typeof target !== "string") throw new TypeError("A credentialed request takes its URL as a string, not a URL object.");
|
|
167
212
|
const origin = originOf(target);
|
|
168
|
-
if (origin !== credential.origin) throw new CredentialDestinationRefused(origin ||
|
|
213
|
+
if (origin !== credential.origin) throw new CredentialDestinationRefused(origin || target, [credential.origin]);
|
|
214
|
+
if (hasUnsafePathSegment(target)) throw new CredentialPathRefused(origin);
|
|
169
215
|
}
|
|
170
216
|
|
|
171
217
|
/**
|
|
@@ -174,9 +220,9 @@ export function assertCredentialDestination(credential, target) {
|
|
|
174
220
|
*/
|
|
175
221
|
export function checkedTransport(requestImpl) {
|
|
176
222
|
return async (url, options, ...rest) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return requestImpl(
|
|
223
|
+
if (!options?.credential) return requestImpl(String(url), options, ...rest);
|
|
224
|
+
assertCredentialDestination(options.credential, url); // a string, checked and passed on as is
|
|
225
|
+
return requestImpl(url, options, ...rest);
|
|
180
226
|
};
|
|
181
227
|
}
|
|
182
228
|
|
|
@@ -188,13 +234,11 @@ function headersWithout(headers, name) {
|
|
|
188
234
|
/**
|
|
189
235
|
* Send a request carrying `credential`: only to the credential's own origin, with the
|
|
190
236
|
* Authorization header set here, and without following redirects. `fetchImpl` is the transport
|
|
191
|
-
* it calls once those checks pass. The URL is
|
|
192
|
-
* checked and sent.
|
|
237
|
+
* it calls once those checks pass. The URL is a string, and that string is both checked and sent.
|
|
193
238
|
*/
|
|
194
239
|
export async function credentialedRequest(credential, url, init = {}, { fetchImpl = globalThis.fetch } = {}) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return fetchImpl(target, {
|
|
240
|
+
assertCredentialDestination(credential, url);
|
|
241
|
+
return fetchImpl(url, {
|
|
198
242
|
...init,
|
|
199
243
|
headers: { ...headersWithout(init.headers, "authorization"), Authorization: `Bearer ${tokens.get(credential)}` },
|
|
200
244
|
redirect: "error",
|
package/src/auth/http.js
CHANGED
|
@@ -448,6 +448,10 @@ export async function requestJson(
|
|
|
448
448
|
if (credential && !isCredential(credential)) {
|
|
449
449
|
throw new TypeError("requestJson: credential must come from src/auth/credential-destinations.js.");
|
|
450
450
|
}
|
|
451
|
+
// Checked here, before any retry or breaker accounting: credentialedRequest takes a URL string.
|
|
452
|
+
if (credential && typeof url !== "string") {
|
|
453
|
+
throw new TypeError("requestJson: a credentialed request takes its URL as a string, not a URL object.");
|
|
454
|
+
}
|
|
451
455
|
// Without a credential, nothing here attaches one: a caller-built Authorization header is refused.
|
|
452
456
|
if (Object.keys(headers || {}).some((name) => /^(?:proxy-)?authorization$/i.test(name))) {
|
|
453
457
|
throw new TypeError("requestJson: send a credential with { credential }, not an Authorization header.");
|
package/src/auth/service.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
writeStoredSession,
|
|
23
23
|
} from "./session-store.js";
|
|
24
24
|
import { authLoginHint } from "../ui/command-hints.js";
|
|
25
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
25
26
|
|
|
26
27
|
/** Default maximum wall-clock wait for browser-based CLI auth approval (ms). */
|
|
27
28
|
export const DEFAULT_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -414,7 +415,7 @@ async function revokeApiToken({ apiUrl, credential, tokenId }) {
|
|
|
414
415
|
if (!normalizedTokenId) {
|
|
415
416
|
return false;
|
|
416
417
|
}
|
|
417
|
-
await requestJsonMutation(buildApiPath(apiUrl, `/api/v1/auth/api-tokens/${
|
|
418
|
+
await requestJsonMutation(buildApiPath(apiUrl, `/api/v1/auth/api-tokens/${urlPathSegment(normalizedTokenId, { label: "tokenId" })}`), {
|
|
418
419
|
method: "DELETE",
|
|
419
420
|
operationName: "revoke-token",
|
|
420
421
|
credential,
|
|
@@ -1055,7 +1056,7 @@ export async function listRuntimeRunEvents({
|
|
|
1055
1056
|
? `?after_event_id=${encodeURIComponent(String(afterEventId))}`
|
|
1056
1057
|
: "";
|
|
1057
1058
|
return requestJson(
|
|
1058
|
-
buildApiPath(apiUrl, `/api/v1/runtime/runs/${
|
|
1059
|
+
buildApiPath(apiUrl, `/api/v1/runtime/runs/${urlPathSegment(runId, { label: "runId" })}/events/list${query}`),
|
|
1059
1060
|
{
|
|
1060
1061
|
method: "GET",
|
|
1061
1062
|
credential,
|
|
@@ -1099,7 +1100,7 @@ export async function getRuntimeRunStatus({
|
|
|
1099
1100
|
runId,
|
|
1100
1101
|
} = {}) {
|
|
1101
1102
|
return requestJson(
|
|
1102
|
-
buildApiPath(apiUrl, `/api/v1/runtime/runs/${
|
|
1103
|
+
buildApiPath(apiUrl, `/api/v1/runtime/runs/${urlPathSegment(runId, { label: "runId" })}/status`),
|
|
1103
1104
|
{
|
|
1104
1105
|
method: "GET",
|
|
1105
1106
|
credential,
|
|
@@ -24,13 +24,21 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Every lookup is INJECTED (`run`), so each branch above -- including every failure
|
|
26
26
|
* branch -- is reachable in a test without a network.
|
|
27
|
+
*
|
|
28
|
+
* The repo, PR number, sha, branch and ref come from the ticket file. Each is checked for
|
|
29
|
+
* its role before `run` is called (src/net/gh-api-path.js); a value that does not fit is
|
|
30
|
+
* refused with an Error, which the gate reports as cannot-verify.
|
|
27
31
|
*/
|
|
28
32
|
|
|
33
|
+
import { ghCommitSha, ghRefSegment, ghRepoSlug } from "../net/gh-api-path.js";
|
|
34
|
+
|
|
29
35
|
/** Shape a resolver must return. Anything else is treated as indeterminate. */
|
|
30
36
|
const YES = true;
|
|
31
37
|
const NO = false;
|
|
32
38
|
const UNKNOWN = null;
|
|
33
39
|
|
|
40
|
+
const PR_NUMBER = /^[1-9][0-9]{0,9}$/;
|
|
41
|
+
|
|
34
42
|
function parseJson(stdout) {
|
|
35
43
|
try {
|
|
36
44
|
return JSON.parse(String(stdout ?? ""));
|
|
@@ -52,7 +60,10 @@ export function createEvidenceResolver({ run, probeDeployedSha } = {}) {
|
|
|
52
60
|
if (typeof run !== "function") throw new TypeError("a run() is required");
|
|
53
61
|
|
|
54
62
|
async function prMerged({ repo, number }) {
|
|
55
|
-
const
|
|
63
|
+
const slug = ghRepoSlug(repo);
|
|
64
|
+
const pr = String(number);
|
|
65
|
+
if (!PR_NUMBER.test(pr)) throw new Error("number must be a pull request number.");
|
|
66
|
+
const res = await run(["pr", "view", pr, "--repo", slug, "--json", "state,mergedAt"]);
|
|
56
67
|
if (!res || !res.ok) return UNKNOWN; // unreachable, missing, or unauthorised
|
|
57
68
|
const data = parseJson(res.stdout);
|
|
58
69
|
if (!data || typeof data.state !== "string") return UNKNOWN;
|
|
@@ -63,7 +74,8 @@ export function createEvidenceResolver({ run, probeDeployedSha } = {}) {
|
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
async function shaOnBranch({ repo, sha, branch }) {
|
|
66
|
-
const
|
|
77
|
+
const basehead = `${ghRefSegment(branch, { label: "branch" })}...${ghCommitSha(sha)}`;
|
|
78
|
+
const res = await run(["api", `repos/${ghRepoSlug(repo)}/compare/${basehead}`, "--jq", ".status"]);
|
|
67
79
|
if (!res || !res.ok) return UNKNOWN;
|
|
68
80
|
const status = String(res.stdout ?? "").trim();
|
|
69
81
|
// GitHub's compare status, base=branch head=sha:
|
|
@@ -75,7 +87,7 @@ export function createEvidenceResolver({ run, probeDeployedSha } = {}) {
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
async function checkConclusive({ repo, ref, check }) {
|
|
78
|
-
const res = await run(["api", `repos/${repo}/commits/${ref}/check-runs`, "--jq", ".check_runs"]);
|
|
90
|
+
const res = await run(["api", `repos/${ghRepoSlug(repo)}/commits/${ghRefSegment(ref)}/check-runs`, "--jq", ".check_runs"]);
|
|
79
91
|
if (!res || !res.ok) return UNKNOWN;
|
|
80
92
|
const runs = parseJson(res.stdout);
|
|
81
93
|
if (!Array.isArray(runs)) return UNKNOWN;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { restrictProgramLookupToPath } from "./program-lookup.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Entry for the bin scripts. Checks the runtime before the rest of the CLI is loaded: importing
|
|
7
|
+
* cli.js loads every command module, and some modules do work as they load (for example, creating
|
|
8
|
+
* ~/.sentinelayer), so this module and program-lookup.js are the only ones a bin script imports
|
|
9
|
+
* statically. runCli repeats the check for callers that import cli.js directly.
|
|
10
|
+
*/
|
|
11
|
+
export async function bootstrapCli() {
|
|
12
|
+
const programLookup = restrictProgramLookupToPath();
|
|
13
|
+
if (!programLookup.ok) {
|
|
14
|
+
console.error(programLookup.message);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { runCli } = await import("./cli.js");
|
|
19
|
+
await runCli();
|
|
20
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -244,7 +244,9 @@ export async function buildCliProgram({
|
|
|
244
244
|
|
|
245
245
|
export async function runCli(rawArgs = process.argv.slice(2)) {
|
|
246
246
|
// Programs named without a path come from PATH only, never from the working directory, before
|
|
247
|
-
// anything is spawned. On a Windows runtime that cannot guarantee that, nothing runs.
|
|
247
|
+
// anything is spawned. On a Windows runtime that cannot guarantee that, nothing runs. The bin
|
|
248
|
+
// scripts check this before loading this module (cli-bootstrap.js); here it covers callers that
|
|
249
|
+
// import runCli directly.
|
|
248
250
|
const programLookup = restrictProgramLookupToPath();
|
|
249
251
|
if (!programLookup.ok) {
|
|
250
252
|
console.error(programLookup.message);
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
stableTimestampForFile,
|
|
39
39
|
writeArtifact,
|
|
40
40
|
} from "./shared.js";
|
|
41
|
+
import { urlPathSegment } from "../../net/url-path.js";
|
|
41
42
|
|
|
42
43
|
function maskEmailForDisplay(value) {
|
|
43
44
|
const email = String(value || "").trim();
|
|
@@ -471,7 +472,7 @@ identity
|
|
|
471
472
|
credentialsMissing: resolvedCredentials.missing,
|
|
472
473
|
trackedIdentity,
|
|
473
474
|
curlPreview: [
|
|
474
|
-
`curl -X POST ${apiUrl}/v1/identities/${
|
|
475
|
+
`curl -X POST ${apiUrl}/v1/identities/${urlPathSegment(identityId, { label: "identityId" })}/revoke \\`,
|
|
475
476
|
` -H \"Authorization: Bearer $AIDENID_API_KEY\" \\`,
|
|
476
477
|
` -H \"X-Org-Id: $AIDENID_ORG_ID\" \\`,
|
|
477
478
|
` -H \"X-Project-Id: $AIDENID_PROJECT_ID\" \\`,
|
|
@@ -638,7 +639,7 @@ identity
|
|
|
638
639
|
credentialsMissing: resolvedCredentials.missing,
|
|
639
640
|
parentIdentityTracked: Boolean(parentIdentity),
|
|
640
641
|
curlPreview: [
|
|
641
|
-
`curl -X POST ${apiUrl}/v1/identities/${
|
|
642
|
+
`curl -X POST ${apiUrl}/v1/identities/${urlPathSegment(parentIdentityId, { label: "identityId" })}/children \\`,
|
|
642
643
|
` -H \"Authorization: Bearer $AIDENID_API_KEY\" \\`,
|
|
643
644
|
` -H \"X-Org-Id: $AIDENID_ORG_ID\" \\`,
|
|
644
645
|
` -H \"X-Project-Id: $AIDENID_PROJECT_ID\" \\`,
|
|
@@ -884,7 +885,7 @@ identity
|
|
|
884
885
|
credentialsMissing: resolvedCredentials.missing,
|
|
885
886
|
parentIdentityTracked: Boolean(trackedIdentity),
|
|
886
887
|
curlPreview: [
|
|
887
|
-
`curl -X POST ${apiUrl}/v1/identities/${
|
|
888
|
+
`curl -X POST ${apiUrl}/v1/identities/${urlPathSegment(identityId, { label: "identityId" })}/revoke-children \\`,
|
|
888
889
|
` -H \"Authorization: Bearer $AIDENID_API_KEY\" \\`,
|
|
889
890
|
` -H \"X-Org-Id: $AIDENID_ORG_ID\" \\`,
|
|
890
891
|
` -H \"X-Project-Id: $AIDENID_PROJECT_ID\" \\`,
|
package/src/commands/chat.js
CHANGED
|
@@ -13,6 +13,7 @@ import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-session-us
|
|
|
13
13
|
import { resolveOutputRoot } from "../config/service.js";
|
|
14
14
|
import { estimateModelCost } from "../cost/tracker.js";
|
|
15
15
|
import { estimateTokens } from "../cost/tokenizer.js";
|
|
16
|
+
import { normalizeSessionId } from "../session/paths.js";
|
|
16
17
|
|
|
17
18
|
function shouldEmitJson(options, command) {
|
|
18
19
|
const local = Boolean(options && options.json);
|
|
@@ -81,7 +82,9 @@ export function registerChatCommand(program) {
|
|
|
81
82
|
outputDirOverride: options.outputDir,
|
|
82
83
|
env: process.env,
|
|
83
84
|
});
|
|
84
|
-
|
|
85
|
+
// An explicit id names the transcript file under chat/sessions/, so it is held to the session-id rule.
|
|
86
|
+
const requestedSessionId = String(options.sessionId || "").trim();
|
|
87
|
+
const sessionId = requestedSessionId ? normalizeSessionId(requestedSessionId) : createSessionId();
|
|
85
88
|
const transcriptPath = path.join(outputRoot, "chat", "sessions", `${sessionId}.jsonl`);
|
|
86
89
|
|
|
87
90
|
const provider = resolveProvider({
|