sentinelayer-cli 0.46.0 → 0.46.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.46.0",
3
+ "version": "0.46.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 + "/api/v1/scan/url/" + scanId;
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/${encodeURIComponent(normalizedIdentityId)}/revoke`,
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/${encodeURIComponent(normalizedIdentityId)}/events`
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/${encodeURIComponent(normalizedIdentityId)}/latest-extraction`,
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/${encodeURIComponent(normalizedParentIdentityId)}/children`,
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/${encodeURIComponent(normalizedIdentityId)}/lineage`,
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/${encodeURIComponent(normalizedIdentityId)}/revoke-children`,
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/${encodeURIComponent(normalizedDomainId)}/verify`,
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/${encodeURIComponent(normalizedDomainId)}/freeze`,
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/${encodeURIComponent(normalizedTargetId)}/verify`,
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/${encodeURIComponent(normalizedTargetId)}`,
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/${encodeURIComponent(model)}:${method}`,
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 = path.join(agentsDirectory, `${agent.id}.json`);
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,
@@ -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 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.
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
- /** Refuse sending `credential` anywhere but its own origin. `target` is the exact URL string sent. */
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 || String(target), [credential.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
- 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);
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 turned into a string once, and that string is both
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
- const target = String(url);
196
- assertCredentialDestination(credential, target);
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.");
@@ -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/${encodeURIComponent(normalizedTokenId)}`), {
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/${encodeURIComponent(String(runId || ""))}/events/list${query}`),
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/${encodeURIComponent(String(runId || ""))}/status`),
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 res = await run(["pr", "view", String(number), "--repo", repo, "--json", "state,mergedAt"]);
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 res = await run(["api", `repos/${repo}/compare/${branch}...${sha}`, "--jq", ".status"]);
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;
@@ -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/${encodeURIComponent(identityId)}/revoke \\`,
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/${encodeURIComponent(parentIdentityId)}/children \\`,
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/${encodeURIComponent(identityId)}/revoke-children \\`,
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\" \\`,
@@ -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
- const sessionId = String(options.sessionId || "").trim() || createSessionId();
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({
@@ -198,6 +198,7 @@ import {
198
198
  import createSentid from "../session/wake/sentid.js";
199
199
  import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
200
200
  import { parseCsvTokens } from "./ai/shared.js";
201
+ import { urlPathSegment } from "../net/url-path.js";
201
202
 
202
203
  function shouldEmitJson(options, command) {
203
204
  const local = Boolean(options && options.json);
@@ -1515,7 +1516,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1515
1516
  if (!apiUrl) {
1516
1517
  return { ok: false, reason: "no_api_url" };
1517
1518
  }
1518
- const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1519
+ const endpoint = `${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}`;
1519
1520
  const credential = await credentialFor(auth);
1520
1521
  const firstAttempt = await fetchRemoteSessionDetail(endpoint, credential);
1521
1522
  const detail = firstAttempt.retryable
@@ -3301,7 +3302,7 @@ export function registerSessionCommand(program) {
3301
3302
  }
3302
3303
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
3303
3304
  const result = await requestJsonMutation(
3304
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/title`,
3305
+ `${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/title`,
3305
3306
  {
3306
3307
  method: "POST",
3307
3308
  operationName: "session.set_title",
@@ -8744,7 +8745,7 @@ export function registerSessionCommand(program) {
8744
8745
  try {
8745
8746
  result = await postAdminSessionMutation({
8746
8747
  session: apiSession,
8747
- pathSuffix: `/api/v1/admin/sessions/${encodeURIComponent(normalizedSessionId)}/kill`,
8748
+ pathSuffix: `/api/v1/admin/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/kill`,
8748
8749
  operationName: "session-admin-kill",
8749
8750
  body: { reason },
8750
8751
  });
package/src/legacy-cli.js CHANGED
@@ -31,6 +31,7 @@ import { normalizeAgentEvent } from "./events/schema.js";
31
31
  import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
32
32
  import { getExpressTemplate, getPackageJsonTemplate, buildReadmeContent } from "./scaffold/templates.js";
33
33
  import { generateScaffold } from "./scaffold/generator.js";
34
+ import { isGhRepoSlug } from "./net/gh-api-path.js";
34
35
  import {
35
36
  getCoordinationEtiquetteItems,
36
37
  renderCoordinationNumberedList,
@@ -117,8 +118,10 @@ function normalizeRepoSlug(value) {
117
118
  return String(value || "").trim().replace(/\.git$/i, "");
118
119
  }
119
120
 
121
+ // Exactly owner/repo as GitHub names them (src/net/gh-api-path.js): the slug goes into gh API
122
+ // paths and --repo values, and into the clone and remote URLs.
120
123
  function isValidRepoSlug(value) {
121
- return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizeRepoSlug(value));
124
+ return isGhRepoSlug(normalizeRepoSlug(value));
122
125
  }
123
126
 
124
127
  function getRepoNameFromSlug(value) {