@hyperdrive.bot/paseo-protocol 0.3.53 → 0.3.54
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/dist/generated/validation/ws-outbound.aot.js +27609 -26838
- package/dist/messages.d.ts +762 -0
- package/dist/messages.js +119 -0
- package/dist/session-references.d.ts +41 -0
- package/dist/session-references.js +175 -0
- package/dist/session-secrets.d.ts +35 -0
- package/dist/session-secrets.js +133 -0
- package/dist/validation/ws-outbound-schema-metadata.d.ts +133 -0
- package/package.json +1 -1
package/dist/messages.js
CHANGED
|
@@ -835,6 +835,26 @@ export const SessionDigestLinkSchema = z.object({
|
|
|
835
835
|
url: z.string(),
|
|
836
836
|
label: z.string().optional(),
|
|
837
837
|
});
|
|
838
|
+
/**
|
|
839
|
+
* A credential row on the session context rail.
|
|
840
|
+
*
|
|
841
|
+
* `value` is ALWAYS safe to render: the daemon runs every incoming value
|
|
842
|
+
* through `normalizeSecretForStorage` (see session-secrets.ts), so a live-shaped
|
|
843
|
+
* token is masked before it is ever persisted, pushed, or cached. That is why
|
|
844
|
+
* the app can offer a reveal button at all -- it uncovers something that was
|
|
845
|
+
* already harmless, not something the view layer was trusted to hide.
|
|
846
|
+
*/
|
|
847
|
+
export const SessionSecretSchema = z.object({
|
|
848
|
+
/** What it is, e.g. "GITLAB_TOKEN", "staging db user". Unique per digest. */
|
|
849
|
+
label: z.string().min(1).max(80),
|
|
850
|
+
/** Safe-to-render value: a placeholder verbatim, or a mask like "••••7f3a". */
|
|
851
|
+
value: z.string().max(200),
|
|
852
|
+
/** True when `value` is a mask rather than the real string. */
|
|
853
|
+
masked: z.boolean(),
|
|
854
|
+
/** Provenance, e.g. "env:GITLAB_TOKEN" or ".env.local". Never a value. */
|
|
855
|
+
source: z.string().max(200).optional(),
|
|
856
|
+
discoveredAt: z.string().optional(),
|
|
857
|
+
});
|
|
838
858
|
/**
|
|
839
859
|
* Session digest — structured, machine-readable per-session metadata so an
|
|
840
860
|
* orchestrator can triage a fleet without replaying transcripts. See
|
|
@@ -848,6 +868,13 @@ export const SessionDigestSchema = z.object({
|
|
|
848
868
|
nextStep: z.string().optional(),
|
|
849
869
|
blockers: z.array(z.string()).optional(),
|
|
850
870
|
links: z.array(SessionDigestLinkSchema).optional(),
|
|
871
|
+
/**
|
|
872
|
+
* Non-live credentials and connection hints a session needs at hand:
|
|
873
|
+
* which token it authenticates with, which test user, which host. Written
|
|
874
|
+
* by the context hook, masked at the daemon boundary. See
|
|
875
|
+
* docs/session-context-rail.md.
|
|
876
|
+
*/
|
|
877
|
+
secrets: z.array(SessionSecretSchema).optional(),
|
|
851
878
|
keyFiles: z.array(z.string()).optional(),
|
|
852
879
|
summary: z.string().optional(),
|
|
853
880
|
accomplishments: z.array(z.string()).optional(),
|
|
@@ -1052,6 +1079,94 @@ export const BackgroundTaskOutputResponseSchema = z.object({
|
|
|
1052
1079
|
requestId: z.string(),
|
|
1053
1080
|
}),
|
|
1054
1081
|
});
|
|
1082
|
+
/**
|
|
1083
|
+
* What the daemon could find out about one external reference (a GitLab
|
|
1084
|
+
* pipeline, a merge request, a GitHub PR, a Jira issue).
|
|
1085
|
+
*
|
|
1086
|
+
* Every field is nullable on purpose. Enrichment needs a provider credential
|
|
1087
|
+
* the daemon may simply not have, and the honest answer to "what is this
|
|
1088
|
+
* pipeline doing" is often "I cannot see it" -- which is what
|
|
1089
|
+
* `unavailableReason` carries, so the card can say so instead of spinning.
|
|
1090
|
+
*/
|
|
1091
|
+
export const ReferenceEnrichmentSchema = z.object({
|
|
1092
|
+
url: z.string(),
|
|
1093
|
+
kind: z.string(),
|
|
1094
|
+
/** Provider status verbatim: "running", "success", "In Review", "merged". */
|
|
1095
|
+
status: z.string().nullable(),
|
|
1096
|
+
/** A normalized bucket the UI colors on, so it never parses `status`. */
|
|
1097
|
+
state: z.enum(["running", "success", "failed", "blocked", "neutral"]),
|
|
1098
|
+
title: z.string().nullable(),
|
|
1099
|
+
/** One supporting line: author, branch, assignee. */
|
|
1100
|
+
detail: z.string().nullable(),
|
|
1101
|
+
/** Countable progress where the provider has it, e.g. "12/14 jobs". */
|
|
1102
|
+
progress: z.string().nullable(),
|
|
1103
|
+
updatedAt: z.string().nullable(),
|
|
1104
|
+
/** Set when nothing could be fetched. Null on success. */
|
|
1105
|
+
unavailableReason: z.string().nullable(),
|
|
1106
|
+
/** True while the underlying resource is still moving and worth re-polling. */
|
|
1107
|
+
live: z.boolean(),
|
|
1108
|
+
});
|
|
1109
|
+
/**
|
|
1110
|
+
* Ask the daemon to enrich reference URLs. On demand (like
|
|
1111
|
+
* `list_background_tasks_request`), never on the snapshot hot path: the app
|
|
1112
|
+
* polls this only while a context rail carrying live references is on screen.
|
|
1113
|
+
*
|
|
1114
|
+
* Credentials stay on the daemon. The app sends URLs and receives status.
|
|
1115
|
+
*/
|
|
1116
|
+
/**
|
|
1117
|
+
* Record session context (external links + non-live credentials) onto an
|
|
1118
|
+
* agent's digest from OUTSIDE the agent's own MCP session.
|
|
1119
|
+
*
|
|
1120
|
+
* This is the channel the context hook uses. `set_session_digest` is
|
|
1121
|
+
* agent-scoped -- only the agent itself can call it -- but a Claude Code hook is
|
|
1122
|
+
* a shell process beside the agent, not the agent, so it needs a door of its
|
|
1123
|
+
* own. Merge semantics: links union by URL, secrets by label (later wins), and
|
|
1124
|
+
* every secret is masked by the daemon before it is stored.
|
|
1125
|
+
*/
|
|
1126
|
+
export const SetAgentContextRequestMessageSchema = z.object({
|
|
1127
|
+
type: z.literal("set_agent_context_request"),
|
|
1128
|
+
agentId: z.string(),
|
|
1129
|
+
links: z
|
|
1130
|
+
.array(z.object({
|
|
1131
|
+
kind: z.enum(["pr", "mr", "issue", "deploy", "preview", "doc", "other"]),
|
|
1132
|
+
url: z.string().min(1).max(500),
|
|
1133
|
+
label: z.string().max(80).optional(),
|
|
1134
|
+
}))
|
|
1135
|
+
.max(20)
|
|
1136
|
+
.optional(),
|
|
1137
|
+
secrets: z
|
|
1138
|
+
.array(z.object({
|
|
1139
|
+
label: z.string().min(1).max(80),
|
|
1140
|
+
value: z.string().max(200),
|
|
1141
|
+
source: z.string().max(200).optional(),
|
|
1142
|
+
}))
|
|
1143
|
+
.max(20)
|
|
1144
|
+
.optional(),
|
|
1145
|
+
requestId: z.string(),
|
|
1146
|
+
});
|
|
1147
|
+
export const SetAgentContextResponseSchema = z.object({
|
|
1148
|
+
type: z.literal("set_agent_context_response"),
|
|
1149
|
+
payload: z.object({
|
|
1150
|
+
agentId: z.string(),
|
|
1151
|
+
digest: SessionDigestSchema.nullable(),
|
|
1152
|
+
/** How many secrets the daemon had to mask, so the hook can report it. */
|
|
1153
|
+
maskedCount: z.number().int().nonnegative(),
|
|
1154
|
+
error: z.string().optional(),
|
|
1155
|
+
requestId: z.string(),
|
|
1156
|
+
}),
|
|
1157
|
+
});
|
|
1158
|
+
export const EnrichReferencesRequestMessageSchema = z.object({
|
|
1159
|
+
type: z.literal("enrich_references_request"),
|
|
1160
|
+
urls: z.array(z.string().min(1).max(500)).max(20),
|
|
1161
|
+
requestId: z.string(),
|
|
1162
|
+
});
|
|
1163
|
+
export const EnrichReferencesResponseSchema = z.object({
|
|
1164
|
+
type: z.literal("enrich_references_response"),
|
|
1165
|
+
payload: z.object({
|
|
1166
|
+
enrichments: z.array(ReferenceEnrichmentSchema),
|
|
1167
|
+
requestId: z.string(),
|
|
1168
|
+
}),
|
|
1169
|
+
});
|
|
1055
1170
|
export const DismissBackgroundTaskRequestMessageSchema = z.object({
|
|
1056
1171
|
type: z.literal("dismiss_background_task_request"),
|
|
1057
1172
|
agentId: z.string(),
|
|
@@ -2604,6 +2719,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|
|
2604
2719
|
DismissBackgroundTaskRequestMessageSchema,
|
|
2605
2720
|
ListBackgroundTasksRequestMessageSchema,
|
|
2606
2721
|
BackgroundTaskOutputRequestMessageSchema,
|
|
2722
|
+
EnrichReferencesRequestMessageSchema,
|
|
2723
|
+
SetAgentContextRequestMessageSchema,
|
|
2607
2724
|
CloseItemsRequestMessageSchema,
|
|
2608
2725
|
UpdateAgentRequestMessageSchema,
|
|
2609
2726
|
ProjectRenameRequestSchema,
|
|
@@ -4796,6 +4913,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|
|
4796
4913
|
BackgroundTaskDismissedMessageSchema,
|
|
4797
4914
|
ListBackgroundTasksResponseSchema,
|
|
4798
4915
|
BackgroundTaskOutputResponseSchema,
|
|
4916
|
+
EnrichReferencesResponseSchema,
|
|
4917
|
+
SetAgentContextResponseSchema,
|
|
4799
4918
|
CloseItemsResponseSchema,
|
|
4800
4919
|
CheckoutStatusResponseSchema,
|
|
4801
4920
|
CheckoutStatusUpdateSchema,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session references -- turning a bare URL sitting in a session's digest into
|
|
3
|
+
* something the UI can render as a card instead of a link.
|
|
4
|
+
*
|
|
5
|
+
* The split matters. Classification is **pure and local**: given a URL string,
|
|
6
|
+
* decide what it is and pull the ids out of the path. Enrichment (is that
|
|
7
|
+
* pipeline green? what is that ticket called?) needs credentials and a network
|
|
8
|
+
* hop, so it lives on the daemon (`enrich_reference_request`) and never here.
|
|
9
|
+
*
|
|
10
|
+
* That split is the whole reason the app can show a useful reference card for a
|
|
11
|
+
* GitLab pipeline the instant the URL lands, and fill in "running / 12 of 14
|
|
12
|
+
* jobs" a moment later, without ever holding a provider token itself.
|
|
13
|
+
*/
|
|
14
|
+
export type SessionReferenceKind = "gitlab-pipeline" | "gitlab-merge-request" | "github-pull-request" | "jira-issue" | "generic";
|
|
15
|
+
export interface SessionReference {
|
|
16
|
+
kind: SessionReferenceKind;
|
|
17
|
+
/** The URL exactly as it was written, so click-through is never lossy. */
|
|
18
|
+
url: string;
|
|
19
|
+
host: string;
|
|
20
|
+
/**
|
|
21
|
+
* Provider-scoped project path: `dev_squad/repo/tooling/paseo` on GitLab,
|
|
22
|
+
* `owner/repo` on GitHub, the site host on Jira. Null when the shape did not
|
|
23
|
+
* carry one.
|
|
24
|
+
*/
|
|
25
|
+
project: string | null;
|
|
26
|
+
/** Pipeline id, MR iid, PR number, or issue key. Null for `generic`. */
|
|
27
|
+
id: string | null;
|
|
28
|
+
/** Compact label for a chip: `!123`, `#45`, `PENG-12`, `pipeline 2009`. */
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Classify one URL. Returns `null` only when the string is not a usable web
|
|
33
|
+
* URL at all -- an unrecognised host still comes back as `generic`, because a
|
|
34
|
+
* link the UI cannot enrich is still a link the UI should show.
|
|
35
|
+
*/
|
|
36
|
+
export declare function classifySessionReference(rawUrl: string): SessionReference | null;
|
|
37
|
+
/** Classify many, dropping unusable strings and de-duplicating by URL. */
|
|
38
|
+
export declare function classifySessionReferences(rawUrls: readonly string[]): SessionReference[];
|
|
39
|
+
/** True when the daemon has a fetcher that can say something about this kind. */
|
|
40
|
+
export declare function isEnrichableReference(reference: SessionReference): boolean;
|
|
41
|
+
//# sourceMappingURL=session-references.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session references -- turning a bare URL sitting in a session's digest into
|
|
3
|
+
* something the UI can render as a card instead of a link.
|
|
4
|
+
*
|
|
5
|
+
* The split matters. Classification is **pure and local**: given a URL string,
|
|
6
|
+
* decide what it is and pull the ids out of the path. Enrichment (is that
|
|
7
|
+
* pipeline green? what is that ticket called?) needs credentials and a network
|
|
8
|
+
* hop, so it lives on the daemon (`enrich_reference_request`) and never here.
|
|
9
|
+
*
|
|
10
|
+
* That split is the whole reason the app can show a useful reference card for a
|
|
11
|
+
* GitLab pipeline the instant the URL lands, and fill in "running / 12 of 14
|
|
12
|
+
* jobs" a moment later, without ever holding a provider token itself.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* GitLab nests groups arbitrarily deep, so the project path is everything
|
|
16
|
+
* before the `/-/` separator. Anything after it is the resource.
|
|
17
|
+
*/
|
|
18
|
+
const GITLAB_RESOURCE_SEPARATOR = "/-/";
|
|
19
|
+
const JIRA_ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
20
|
+
function safeParse(rawUrl) {
|
|
21
|
+
const trimmed = rawUrl.trim();
|
|
22
|
+
if (trimmed.length === 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(trimmed);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Only web references get cards. A `file://` or `mailto:` is still a link,
|
|
33
|
+
// just not something a provider can be asked about.
|
|
34
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
function segments(pathname) {
|
|
40
|
+
return pathname.split("/").filter((segment) => segment.length > 0);
|
|
41
|
+
}
|
|
42
|
+
function generic(url, parsed) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "generic",
|
|
45
|
+
url,
|
|
46
|
+
host: parsed.host,
|
|
47
|
+
project: null,
|
|
48
|
+
id: null,
|
|
49
|
+
label: parsed.host,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function classifyGitLab(url, parsed) {
|
|
53
|
+
const separatorIndex = parsed.pathname.indexOf(GITLAB_RESOURCE_SEPARATOR);
|
|
54
|
+
if (separatorIndex === -1) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const project = segments(parsed.pathname.slice(0, separatorIndex)).join("/");
|
|
58
|
+
const resource = segments(parsed.pathname.slice(separatorIndex + GITLAB_RESOURCE_SEPARATOR.length));
|
|
59
|
+
if (project.length === 0 || resource.length < 2) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const [kind, id] = resource;
|
|
63
|
+
if (kind === "pipelines" && /^\d+$/.test(id)) {
|
|
64
|
+
return {
|
|
65
|
+
kind: "gitlab-pipeline",
|
|
66
|
+
url,
|
|
67
|
+
host: parsed.host,
|
|
68
|
+
project,
|
|
69
|
+
id,
|
|
70
|
+
label: `pipeline ${id}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (kind === "merge_requests" && /^\d+$/.test(id)) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "gitlab-merge-request",
|
|
76
|
+
url,
|
|
77
|
+
host: parsed.host,
|
|
78
|
+
project,
|
|
79
|
+
id,
|
|
80
|
+
label: `!${id}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function classifyGitHub(url, parsed) {
|
|
86
|
+
if (parsed.host !== "github.com" && !parsed.host.endsWith(".github.com")) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const parts = segments(parsed.pathname);
|
|
90
|
+
if (parts.length < 4) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const [owner, repo, resource, id] = parts;
|
|
94
|
+
if (resource !== "pull" || !/^\d+$/.test(id)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
kind: "github-pull-request",
|
|
99
|
+
url,
|
|
100
|
+
host: parsed.host,
|
|
101
|
+
project: `${owner}/${repo}`,
|
|
102
|
+
id,
|
|
103
|
+
label: `#${id}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Jira writes the same issue two ways: the canonical `/browse/KEY-1`, and a
|
|
108
|
+
* board URL that carries the issue in `?selectedIssue=KEY-1`. Both are what a
|
|
109
|
+
* human actually pastes, so both resolve to the same card.
|
|
110
|
+
*/
|
|
111
|
+
function classifyJira(url, parsed) {
|
|
112
|
+
const selected = parsed.searchParams.get("selectedIssue");
|
|
113
|
+
if (selected && JIRA_ISSUE_KEY.test(selected)) {
|
|
114
|
+
return {
|
|
115
|
+
kind: "jira-issue",
|
|
116
|
+
url,
|
|
117
|
+
host: parsed.host,
|
|
118
|
+
project: parsed.host,
|
|
119
|
+
id: selected,
|
|
120
|
+
label: selected,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const parts = segments(parsed.pathname);
|
|
124
|
+
const browseIndex = parts.indexOf("browse");
|
|
125
|
+
if (browseIndex === -1) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const key = parts[browseIndex + 1];
|
|
129
|
+
if (!key || !JIRA_ISSUE_KEY.test(key)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
kind: "jira-issue",
|
|
134
|
+
url,
|
|
135
|
+
host: parsed.host,
|
|
136
|
+
project: parsed.host,
|
|
137
|
+
id: key,
|
|
138
|
+
label: key,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Classify one URL. Returns `null` only when the string is not a usable web
|
|
143
|
+
* URL at all -- an unrecognised host still comes back as `generic`, because a
|
|
144
|
+
* link the UI cannot enrich is still a link the UI should show.
|
|
145
|
+
*/
|
|
146
|
+
export function classifySessionReference(rawUrl) {
|
|
147
|
+
const parsed = safeParse(rawUrl);
|
|
148
|
+
if (!parsed) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const url = rawUrl.trim();
|
|
152
|
+
return (classifyGitLab(url, parsed) ??
|
|
153
|
+
classifyGitHub(url, parsed) ??
|
|
154
|
+
classifyJira(url, parsed) ??
|
|
155
|
+
generic(url, parsed));
|
|
156
|
+
}
|
|
157
|
+
/** Classify many, dropping unusable strings and de-duplicating by URL. */
|
|
158
|
+
export function classifySessionReferences(rawUrls) {
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const rawUrl of rawUrls) {
|
|
162
|
+
const reference = classifySessionReference(rawUrl);
|
|
163
|
+
if (!reference || seen.has(reference.url)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
seen.add(reference.url);
|
|
167
|
+
out.push(reference);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** True when the daemon has a fetcher that can say something about this kind. */
|
|
172
|
+
export function isEnrichableReference(reference) {
|
|
173
|
+
return reference.kind !== "generic";
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=session-references.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session secrets -- the credential rows a session's context rail shows.
|
|
3
|
+
*
|
|
4
|
+
* The point of this module is one rule, and the rule is the reason it is in the
|
|
5
|
+
* protocol package rather than the app: **a secret is masked on the way IN, at
|
|
6
|
+
* the daemon boundary, never on the way out at the UI.**
|
|
7
|
+
*
|
|
8
|
+
* A masked-at-render design puts the real string in the snapshot, the push
|
|
9
|
+
* payload, the query cache and every log line that ever prints one, and asks
|
|
10
|
+
* the view layer to be careful forever. Masking at write means the dangerous
|
|
11
|
+
* value never enters paseo at all, so the reveal button can only ever uncover
|
|
12
|
+
* something that was already safe to hold.
|
|
13
|
+
*/
|
|
14
|
+
/** True when `value` looks like something that would still work if pasted. */
|
|
15
|
+
export declare function looksLikeLiveSecret(value: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Replace a value with something safe to store and render. Keeps a short tail
|
|
18
|
+
* so "is this the same token I put in the CI variable?" stays answerable, which
|
|
19
|
+
* is the only reason to show a credential row at all.
|
|
20
|
+
*/
|
|
21
|
+
export declare function maskSecretValue(value: string): string;
|
|
22
|
+
export interface NormalizedSecret {
|
|
23
|
+
value: string;
|
|
24
|
+
masked: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The daemon-boundary call. Give it whatever the hook found; get back what is
|
|
28
|
+
* safe to persist, plus whether it had to be masked.
|
|
29
|
+
*
|
|
30
|
+
* `declaredMasked` lets a caller that already masked upstream say so, and is
|
|
31
|
+
* ignored when the value still looks live -- a caller cannot assert its way
|
|
32
|
+
* past the check.
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeSecretForStorage(value: string, declaredMasked?: boolean): NormalizedSecret;
|
|
35
|
+
//# sourceMappingURL=session-secrets.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session secrets -- the credential rows a session's context rail shows.
|
|
3
|
+
*
|
|
4
|
+
* The point of this module is one rule, and the rule is the reason it is in the
|
|
5
|
+
* protocol package rather than the app: **a secret is masked on the way IN, at
|
|
6
|
+
* the daemon boundary, never on the way out at the UI.**
|
|
7
|
+
*
|
|
8
|
+
* A masked-at-render design puts the real string in the snapshot, the push
|
|
9
|
+
* payload, the query cache and every log line that ever prints one, and asks
|
|
10
|
+
* the view layer to be careful forever. Masking at write means the dangerous
|
|
11
|
+
* value never enters paseo at all, so the reveal button can only ever uncover
|
|
12
|
+
* something that was already safe to hold.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Shapes that are live credentials by construction. These are matched on the
|
|
16
|
+
* VALUE, so the caller does not get to opt out by naming the field innocently.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately conservative: it is fine to mask a harmless string, and not fine
|
|
19
|
+
* to store a live one. When in doubt, this list masks.
|
|
20
|
+
*/
|
|
21
|
+
const LIVE_SECRET_PATTERNS = [
|
|
22
|
+
/\bglpat-[A-Za-z0-9_-]{12,}/, // GitLab personal access token
|
|
23
|
+
/\bglrt-[A-Za-z0-9_-]{12,}/, // GitLab runner token
|
|
24
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/, // GitHub token family
|
|
25
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
26
|
+
/\bsk-[A-Za-z0-9_-]{20,}/, // OpenAI-style
|
|
27
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}/, // Anthropic
|
|
28
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack
|
|
29
|
+
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key id
|
|
30
|
+
/\bASIA[0-9A-Z]{16}\b/, // AWS temporary access key id
|
|
31
|
+
/\bAIza[0-9A-Za-z_-]{30,}/, // Google API key
|
|
32
|
+
/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, // JWT
|
|
33
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Backstop for providers whose prefix nobody has added yet. New vendors mint new
|
|
37
|
+
* prefixes constantly, so length alone used to be the rule here -- and that was
|
|
38
|
+
* wrong in a way only running it revealed: a preview hostname
|
|
39
|
+
* (`paseo-app-preview-feat-session-context-rail.workers.dev`) was masked, which
|
|
40
|
+
* is the exact opposite of useful, since a connection hint is half the reason
|
|
41
|
+
* this section exists.
|
|
42
|
+
*
|
|
43
|
+
* Entropy is NOT the fix. Measured against real samples, a 32-char hex key
|
|
44
|
+
* scores 3.91 bits/char and that hostname scores 3.95: any threshold that
|
|
45
|
+
* catches the key also catches the hostname. What DOES separate them is the
|
|
46
|
+
* longest unbroken alphanumeric run, because human-readable identifiers are
|
|
47
|
+
* words joined by separators and credentials are not:
|
|
48
|
+
*
|
|
49
|
+
* hostname / path / branch / email .... longest run 7-8
|
|
50
|
+
* AWS secret key ...................... 13
|
|
51
|
+
* base64 blob, hex key ................ 30+
|
|
52
|
+
*/
|
|
53
|
+
const OPAQUE_MIN_LENGTH = 24;
|
|
54
|
+
const OPAQUE_SHAPE = /^[A-Za-z0-9+/=_.-]+$/;
|
|
55
|
+
const OPAQUE_MIN_RUN = 12;
|
|
56
|
+
function longestAlphanumericRun(value) {
|
|
57
|
+
let longest = 0;
|
|
58
|
+
let current = 0;
|
|
59
|
+
for (const char of value) {
|
|
60
|
+
if (/[A-Za-z0-9]/.test(char)) {
|
|
61
|
+
current += 1;
|
|
62
|
+
longest = Math.max(longest, current);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
current = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return longest;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Shapes that are addresses, not credentials. A URL, a hostname, an email and
|
|
72
|
+
* a filesystem path are exactly the connection hints the rail is meant to show,
|
|
73
|
+
* so they short-circuit the length backstop. They do NOT short-circuit the
|
|
74
|
+
* vendor patterns above: a JWT contains dots and is still a JWT.
|
|
75
|
+
*/
|
|
76
|
+
function isHumanReadableHint(value) {
|
|
77
|
+
if (value.includes("://") || value.includes("@")) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return value.startsWith("/") || value.startsWith("~/") || value.startsWith("./");
|
|
84
|
+
}
|
|
85
|
+
/** True when `value` looks like something that would still work if pasted. */
|
|
86
|
+
export function looksLikeLiveSecret(value) {
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
if (trimmed.length === 0) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
// Vendor prefixes are decisive and are checked first, so a token that happens
|
|
92
|
+
// to contain a dot or a slash is never argued out of being a token.
|
|
93
|
+
if (LIVE_SECRET_PATTERNS.some((pattern) => pattern.test(trimmed))) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (isHumanReadableHint(trimmed)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return (trimmed.length >= OPAQUE_MIN_LENGTH &&
|
|
100
|
+
OPAQUE_SHAPE.test(trimmed) &&
|
|
101
|
+
longestAlphanumericRun(trimmed) >= OPAQUE_MIN_RUN);
|
|
102
|
+
}
|
|
103
|
+
/** How many trailing characters a mask keeps so a human can tell two apart. */
|
|
104
|
+
const MASK_TAIL = 4;
|
|
105
|
+
const MASK_BULLETS = "••••";
|
|
106
|
+
/**
|
|
107
|
+
* Replace a value with something safe to store and render. Keeps a short tail
|
|
108
|
+
* so "is this the same token I put in the CI variable?" stays answerable, which
|
|
109
|
+
* is the only reason to show a credential row at all.
|
|
110
|
+
*/
|
|
111
|
+
export function maskSecretValue(value) {
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
if (trimmed.length <= MASK_TAIL) {
|
|
114
|
+
return MASK_BULLETS;
|
|
115
|
+
}
|
|
116
|
+
return `${MASK_BULLETS}${trimmed.slice(-MASK_TAIL)}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The daemon-boundary call. Give it whatever the hook found; get back what is
|
|
120
|
+
* safe to persist, plus whether it had to be masked.
|
|
121
|
+
*
|
|
122
|
+
* `declaredMasked` lets a caller that already masked upstream say so, and is
|
|
123
|
+
* ignored when the value still looks live -- a caller cannot assert its way
|
|
124
|
+
* past the check.
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeSecretForStorage(value, declaredMasked) {
|
|
127
|
+
const trimmed = value.trim();
|
|
128
|
+
if (looksLikeLiveSecret(trimmed)) {
|
|
129
|
+
return { value: maskSecretValue(trimmed), masked: true };
|
|
130
|
+
}
|
|
131
|
+
return { value: trimmed, masked: declaredMasked ?? false };
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=session-secrets.js.map
|