sliccy 1.54.0 → 1.55.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.
Files changed (64) hide show
  1. package/README.md +7 -0
  2. package/dist/node-server/index.js +80 -5
  3. package/dist/node-server/runtime-flags.d.ts +2 -0
  4. package/dist/node-server/runtime-flags.js +14 -0
  5. package/dist/node-server/secrets/domain-match.d.ts +20 -0
  6. package/dist/node-server/secrets/domain-match.js +37 -0
  7. package/dist/node-server/secrets/env-file.d.ts +23 -0
  8. package/dist/node-server/secrets/env-file.js +48 -0
  9. package/dist/node-server/secrets/env-secret-store.d.ts +19 -0
  10. package/dist/node-server/secrets/env-secret-store.js +101 -0
  11. package/dist/node-server/secrets/index.d.ts +5 -0
  12. package/dist/node-server/secrets/index.js +4 -0
  13. package/dist/node-server/secrets/masking.d.ts +21 -0
  14. package/dist/node-server/secrets/masking.js +82 -0
  15. package/dist/node-server/secrets/proxy-manager.d.ts +83 -0
  16. package/dist/node-server/secrets/proxy-manager.js +141 -0
  17. package/dist/node-server/secrets/types.d.ts +29 -0
  18. package/dist/node-server/secrets/types.js +1 -0
  19. package/dist/ui/assets/{anthropic-C4vxs4g1.js → anthropic-Ctm13Y4z.js} +1 -1
  20. package/dist/ui/assets/{azure-openai-responses-CQGUi04X.js → azure-openai-responses-C0Fcf6OA.js} +1 -1
  21. package/dist/ui/assets/{cdp-Ch8e9w_F.js → cdp-B4yzwpE0.js} +2 -2
  22. package/dist/ui/assets/{dist-D_HlTg57.js → dist-B3JYrjq2.js} +2 -2
  23. package/dist/ui/assets/{es-BB5XVW8l.js → es-DlWKrNhH.js} +1 -1
  24. package/dist/ui/assets/{google-DlWVI40D.js → google-D_JFv6N-.js} +1 -1
  25. package/dist/ui/assets/{google-gemini-cli-Cij1vUE6.js → google-gemini-cli-BjwSjH_H.js} +1 -1
  26. package/dist/ui/assets/{google-shared-D0cKpK1A.js → google-shared-1MEdugC3.js} +1 -1
  27. package/dist/ui/assets/{google-vertex-qWV5CXzF.js → google-vertex-MzEI1-me.js} +1 -1
  28. package/dist/ui/assets/{index-BgzzSeH7.js → index-D0ffClMg.js} +122 -93
  29. package/dist/ui/assets/lick-manager-IFElAmqB.js +1 -0
  30. package/dist/ui/assets/{magick-wasm-JayMw6yo.js → magick-wasm-cJ5msdlA.js} +1 -1
  31. package/dist/ui/assets/{mistral-DtJjgVHB.js → mistral-DcpebUMg.js} +1 -1
  32. package/dist/ui/assets/{openai-codex-responses-_MfKWbO7.js → openai-codex-responses-g4roCtlP.js} +1 -1
  33. package/dist/ui/assets/{openai-completions-_u7NXbnG.js → openai-completions-CTyIshQV.js} +1 -1
  34. package/dist/ui/assets/{openai-responses-3JWIezrg.js → openai-responses-DEGSelVp.js} +1 -1
  35. package/dist/ui/assets/{openai-responses-shared-CXRfi1gl.js → openai-responses-shared-DQA10qMf.js} +1 -1
  36. package/dist/ui/assets/{provider-settings-Q4v0fqFS.js → provider-settings-8zmqtsZN.js} +1 -1
  37. package/dist/ui/assets/{provider-settings-CVTu49GU.js → provider-settings-DvGHKYX6.js} +4 -4
  38. package/dist/ui/assets/providers-C3FxylOY.js +1 -0
  39. package/dist/ui/assets/{pyodide-BB8mNECZ.js → pyodide-CDyS-YOW.js} +2 -2
  40. package/dist/ui/assets/secret-env-Dy73TZRP.js +1 -0
  41. package/dist/ui/assets/secret-masking-CfbASE5x.js +1 -0
  42. package/dist/ui/assets/shell-NUxCukI4.js +1 -0
  43. package/dist/ui/assets/sprinkle-renderer-Cv5hoBK5.js +1 -0
  44. package/dist/ui/assets/{sql-wasm-qCsadHEF.js → sql-wasm-Cx-UtEsl.js} +1 -1
  45. package/dist/ui/index.html +4 -4
  46. package/dist/ui/packages/webapp/index.html +4 -4
  47. package/package.json +1 -1
  48. package/dist/ui/assets/lick-manager-DRxmzPRX.js +0 -1
  49. package/dist/ui/assets/providers-CtyOAYB2.js +0 -1
  50. package/dist/ui/assets/shell-CmUsf9_D.js +0 -1
  51. package/dist/ui/assets/sprinkle-renderer-DpWOA1rr.js +0 -1
  52. /package/dist/ui/assets/{__vite-browser-external-CmLpjmp4.js → __vite-browser-external-fVHSRdZ9.js} +0 -0
  53. /package/dist/ui/assets/{addon-fit-CnTv21Qe.js → addon-fit-CJBYijzN.js} +0 -0
  54. /package/dist/ui/assets/{bsh-watchdog-DUSt_vXs.js → bsh-watchdog-Dfc2Sii6.js} +0 -0
  55. /package/dist/ui/assets/{dist-CwgfYnJh.js → dist-B_UNq8pG.js} +0 -0
  56. /package/dist/ui/assets/{github-copilot-headers-BNWb_OCE.js → github-copilot-headers-BpGzQFlb.js} +0 -0
  57. /package/dist/ui/assets/{hash-CsofV07h.js → hash-K0cSe8jy.js} +0 -0
  58. /package/dist/ui/assets/{lick-manager-proxy-CogaR9kt.js → lick-manager-proxy-BsVSy3SO.js} +0 -0
  59. /package/dist/ui/assets/{oauth-service-BoyG8vZW.js → oauth-service-t9Y4KdOX.js} +0 -0
  60. /package/dist/ui/assets/{offscreen-client-B8UaOgaJ.js → offscreen-client-BIdj4gf1.js} +0 -0
  61. /package/dist/ui/assets/{pdfjs-DkB4gfOb.js → pdfjs-DqPUb7Z_.js} +0 -0
  62. /package/dist/ui/assets/{sanitize-unicode-Dw-JFaZE.js → sanitize-unicode-BA6bNBBh.js} +0 -0
  63. /package/dist/ui/assets/{src-CHtOpyP_.js → src-CgSXdUB7.js} +0 -0
  64. /package/dist/ui/assets/{xterm-CAZt1iF4.js → xterm-DHgoOdoP.js} +0 -0
package/README.md CHANGED
@@ -155,6 +155,12 @@ To use SLICC, you need an LLM provider. SLICC is very much a BYOT (bring your ow
155
155
 
156
156
  The other providers are in YMMV territory. Please file an issue if you find them working or broken.
157
157
 
158
+ ## Secrets
159
+
160
+ SLICC can safely manage API keys, tokens, and credentials with domain-scoped injection. The agent never sees real secret values — only masked placeholders — and secrets are only injected into requests destined for authorized domains. This protects against prompt-injection attacks that try to exfiltrate credentials.
161
+
162
+ See [docs/secrets.md](docs/secrets.md) for setup instructions.
163
+
158
164
  ## Related projects and lineage
159
165
 
160
166
  SLICC is part of the [AI Ecoverse](https://github.com/ai-ecoverse), a growing set of AI-native tools and workflows. Its distinctive angle is simple: browser-native, practical, and job-oriented.
@@ -173,5 +179,6 @@ If you want to go deeper, the detailed docs live here:
173
179
  - [Architecture](docs/architecture.md)
174
180
  - [Testing](docs/testing.md)
175
181
  - [Shell reference](docs/shell-reference.md)
182
+ - [Secrets](docs/secrets.md)
176
183
  - [Adding features](docs/adding-features.md)
177
184
  - [Electron notes](docs/electron.md)
@@ -14,6 +14,8 @@ import { resolveCliBrowserLaunchUrl } from './launch-url.js';
14
14
  import { parseCliRuntimeFlags } from './runtime-flags.js';
15
15
  import { FileLogger } from './file-logger.js';
16
16
  import { CliLogDedup } from './cli-log-dedup.js';
17
+ import { EnvSecretStore } from './secrets/env-secret-store.js';
18
+ import { SecretProxyManager } from './secrets/proxy-manager.js';
17
19
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
18
20
  const PROJECT_ROOT = resolve(__dirname, '..', '..');
19
21
  const RUNTIME_FLAGS = parseCliRuntimeFlags(process.argv.slice(2));
@@ -483,6 +485,16 @@ async function main() {
483
485
  });
484
486
  }
485
487
  // 3. Set up express app with request logging
488
+ const secretProxy = new SecretProxyManager();
489
+ try {
490
+ await secretProxy.reload();
491
+ if (secretProxy.hasSecrets()) {
492
+ console.log(`Loaded ${secretProxy.getMaskedEntries().length} secrets for fetch-proxy injection`);
493
+ }
494
+ }
495
+ catch (err) {
496
+ console.warn('Failed to load secrets:', err instanceof Error ? err.message : err);
497
+ }
486
498
  const app = express();
487
499
  app.use(requestLogger);
488
500
  // ---------------------------------------------------------------------------
@@ -755,6 +767,33 @@ async function main() {
755
767
  broadcastLickEvent({ type: 'handoff_event', payload });
756
768
  res.json({ ok: true });
757
769
  });
770
+ // Secret management API — direct .env file access (no browser needed)
771
+ const secretStore = new EnvSecretStore(RUNTIME_FLAGS.envFile ?? undefined);
772
+ app.get('/api/secrets', (_req, res) => {
773
+ try {
774
+ const entries = secretStore.list();
775
+ res.json(entries);
776
+ }
777
+ catch (err) {
778
+ res
779
+ .status(500)
780
+ .json({ error: err instanceof Error ? err.message : 'Failed to list secrets' });
781
+ }
782
+ });
783
+ // Masked secrets endpoint — returns name + maskedValue pairs for shell env population.
784
+ // The browser fetches this at shell init to populate env vars with masked values.
785
+ // Real values are never exposed; only deterministic session-scoped masks.
786
+ app.get('/api/secrets/masked', (_req, res) => {
787
+ try {
788
+ const entries = secretProxy.getMaskedEntries();
789
+ res.json(entries);
790
+ }
791
+ catch (err) {
792
+ res
793
+ .status(500)
794
+ .json({ error: err instanceof Error ? err.message : 'Failed to get masked secrets' });
795
+ }
796
+ });
758
797
  // Fetch proxy — forwards cross-origin requests from the browser to bypass CORS.
759
798
  // Used by just-bash's curl which calls the browser's fetch() API.
760
799
  // Note: express.json() may have already parsed the body, so we check req.body first.
@@ -852,9 +891,37 @@ async function main() {
852
891
  // Without this, Cloudflare may Brotli-compress the response, the proxy strips
853
892
  // Content-Encoding (line below), and the browser receives compressed garbage.
854
893
  headers['accept-encoding'] = 'identity';
894
+ // --- Secret injection: unmask headers ---
895
+ let targetHostname;
896
+ try {
897
+ targetHostname = new URL(targetUrl).hostname;
898
+ }
899
+ catch {
900
+ targetHostname = '';
901
+ }
902
+ if (secretProxy.hasSecrets()) {
903
+ // Unmask request headers (replace masked values with real, validate domain)
904
+ const headerResult = secretProxy.unmaskHeaders(headers, targetHostname);
905
+ if (headerResult.forbidden) {
906
+ res.status(403).json({
907
+ error: `Secret "${headerResult.forbidden.secretName}" is not allowed for domain "${headerResult.forbidden.hostname}"`,
908
+ });
909
+ return;
910
+ }
911
+ }
855
912
  if (Object.keys(headers).length > 0)
856
913
  fetchInit.headers = headers;
857
914
  if (rawBody.length > 0 && !['GET', 'HEAD'].includes(req.method)) {
915
+ // --- Secret injection: unmask request body ---
916
+ // Body uses unmaskBody: domain mismatches leave the masked value as-is
917
+ // (safe/meaningless) rather than rejecting. This avoids false 403s when
918
+ // LLM conversation context contains masked secrets sent to non-matching
919
+ // domains like Bedrock.
920
+ if (secretProxy.hasSecrets()) {
921
+ const bodyStr = rawBody.toString('utf-8');
922
+ const bodyResult = secretProxy.unmaskBody(bodyStr, targetHostname);
923
+ rawBody = Buffer.from(bodyResult.text, 'utf-8');
924
+ }
858
925
  // Buffer extends Uint8Array which is a valid fetch body at runtime.
859
926
  fetchInit.body = rawBody;
860
927
  }
@@ -876,16 +943,24 @@ async function main() {
876
943
  lower !== 'www-authenticate' &&
877
944
  lower !== 'set-cookie' &&
878
945
  !lower.startsWith('x-proxy-')) {
879
- res.setHeader(k, v);
946
+ // Scrub real secret values from response headers
947
+ res.setHeader(k, secretProxy.scrubResponse(v));
880
948
  }
881
949
  });
882
950
  if (setCookieValues.length > 0) {
883
- res.setHeader('X-Proxy-Set-Cookie', JSON.stringify(setCookieValues));
951
+ res.setHeader('X-Proxy-Set-Cookie', secretProxy.scrubResponse(JSON.stringify(setCookieValues)));
884
952
  }
885
- // Send body as raw binary - explicitly set content-length and use end()
886
- // instead of send() to avoid any Express middleware transformations
953
+ // Send body scrub real secret values from response body (text-only)
887
954
  const body = await upstream.arrayBuffer();
888
- const buffer = Buffer.from(body);
955
+ let buffer = Buffer.from(body);
956
+ if (secretProxy.hasSecrets()) {
957
+ const ct = (upstream.headers.get('content-type') ?? '').toLowerCase();
958
+ const isText = ct.startsWith('text/') || ct.startsWith('application/json') || ct.includes('charset=');
959
+ if (isText) {
960
+ const scrubbed = secretProxy.scrubResponse(buffer.toString('utf-8'));
961
+ buffer = Buffer.from(scrubbed, 'utf-8');
962
+ }
963
+ }
889
964
  res.setHeader('Content-Length', buffer.length);
890
965
  res.end(buffer);
891
966
  }
@@ -17,6 +17,8 @@ export interface CliRuntimeFlags {
17
17
  logDir: string | null;
18
18
  /** Initial prompt to auto-submit when the UI loads */
19
19
  prompt: string | null;
20
+ /** Path to a .env file for secrets */
21
+ envFile: string | null;
20
22
  version: boolean;
21
23
  }
22
24
  export declare const DEFAULT_CLI_CDP_PORT = 9222;
@@ -20,6 +20,7 @@ export function parseCliRuntimeFlags(argv) {
20
20
  let logLevel = 'info';
21
21
  let logDir = null;
22
22
  let prompt = null;
23
+ let envFile = null;
23
24
  let version = false;
24
25
  for (let index = 0; index < argv.length; index += 1) {
25
26
  const arg = argv[index];
@@ -66,6 +67,18 @@ export function parseCliRuntimeFlags(argv) {
66
67
  }
67
68
  continue;
68
69
  }
70
+ if (arg.startsWith('--env-file=')) {
71
+ envFile = arg.slice('--env-file='.length) || null;
72
+ continue;
73
+ }
74
+ if (arg === '--env-file') {
75
+ const nextArg = argv[index + 1];
76
+ if (nextArg && !nextArg.startsWith('--')) {
77
+ envFile = nextArg;
78
+ index += 1;
79
+ }
80
+ continue;
81
+ }
69
82
  if (arg === '--electron') {
70
83
  electron = true;
71
84
  const nextArg = argv[index + 1];
@@ -156,6 +169,7 @@ export function parseCliRuntimeFlags(argv) {
156
169
  logLevel,
157
170
  logDir,
158
171
  prompt,
172
+ envFile,
159
173
  version,
160
174
  };
161
175
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Domain glob matching for secret domain allowlists.
3
+ *
4
+ * Supports patterns like:
5
+ * - `api.github.com` — exact match
6
+ * - `*.github.com` — matches any subdomain of github.com
7
+ * - `*` — matches any domain
8
+ */
9
+ /**
10
+ * Check if a hostname matches a domain glob pattern.
11
+ *
12
+ * @param hostname - The hostname to check (e.g., "api.github.com")
13
+ * @param pattern - The glob pattern (e.g., "*.github.com")
14
+ * @returns true if the hostname matches the pattern
15
+ */
16
+ export declare function matchDomain(hostname: string, pattern: string): boolean;
17
+ /**
18
+ * Check if a hostname matches any pattern in a list of domain globs.
19
+ */
20
+ export declare function matchesDomains(hostname: string, patterns: string[]): boolean;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Domain glob matching for secret domain allowlists.
3
+ *
4
+ * Supports patterns like:
5
+ * - `api.github.com` — exact match
6
+ * - `*.github.com` — matches any subdomain of github.com
7
+ * - `*` — matches any domain
8
+ */
9
+ /**
10
+ * Check if a hostname matches a domain glob pattern.
11
+ *
12
+ * @param hostname - The hostname to check (e.g., "api.github.com")
13
+ * @param pattern - The glob pattern (e.g., "*.github.com")
14
+ * @returns true if the hostname matches the pattern
15
+ */
16
+ export function matchDomain(hostname, pattern) {
17
+ const h = hostname.toLowerCase();
18
+ const p = pattern.toLowerCase();
19
+ // Wildcard matches everything
20
+ if (p === '*')
21
+ return true;
22
+ // Exact match
23
+ if (h === p)
24
+ return true;
25
+ // Glob match: *.example.com matches sub.example.com but not example.com
26
+ if (p.startsWith('*.')) {
27
+ const suffix = p.slice(1); // ".example.com"
28
+ return h.endsWith(suffix) && h.length > suffix.length;
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Check if a hostname matches any pattern in a list of domain globs.
34
+ */
35
+ export function matchesDomains(hostname, patterns) {
36
+ return patterns.some((pattern) => matchDomain(hostname, pattern));
37
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Simple .env parser and writer — no external dependencies.
3
+ *
4
+ * Supports:
5
+ * - KEY=VALUE lines (no quoting required)
6
+ * - Single- and double-quoted values (quotes stripped)
7
+ * - Comment lines starting with #
8
+ * - Blank lines and comments are stripped on round-trip
9
+ */
10
+ export interface EnvEntry {
11
+ key: string;
12
+ value: string;
13
+ }
14
+ /**
15
+ * Parse .env file content into key-value pairs.
16
+ * Blank lines and comments are skipped.
17
+ */
18
+ export declare function parseEnvFile(content: string): EnvEntry[];
19
+ /**
20
+ * Serialize key-value pairs back to .env format.
21
+ * Values containing spaces, #, or quotes are double-quoted.
22
+ */
23
+ export declare function serializeEnvFile(entries: EnvEntry[]): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Simple .env parser and writer — no external dependencies.
3
+ *
4
+ * Supports:
5
+ * - KEY=VALUE lines (no quoting required)
6
+ * - Single- and double-quoted values (quotes stripped)
7
+ * - Comment lines starting with #
8
+ * - Blank lines and comments are stripped on round-trip
9
+ */
10
+ /**
11
+ * Parse .env file content into key-value pairs.
12
+ * Blank lines and comments are skipped.
13
+ */
14
+ export function parseEnvFile(content) {
15
+ const entries = [];
16
+ for (const raw of content.split('\n')) {
17
+ const line = raw.trim();
18
+ if (line === '' || line.startsWith('#'))
19
+ continue;
20
+ const eqIndex = line.indexOf('=');
21
+ if (eqIndex === -1)
22
+ continue; // malformed line, skip
23
+ const key = line.slice(0, eqIndex).trim();
24
+ let value = line.slice(eqIndex + 1).trim();
25
+ // Strip matching quotes
26
+ if ((value.startsWith('"') && value.endsWith('"')) ||
27
+ (value.startsWith("'") && value.endsWith("'"))) {
28
+ value = value.slice(1, -1);
29
+ }
30
+ if (key) {
31
+ entries.push({ key, value });
32
+ }
33
+ }
34
+ return entries;
35
+ }
36
+ /**
37
+ * Serialize key-value pairs back to .env format.
38
+ * Values containing spaces, #, or quotes are double-quoted.
39
+ */
40
+ export function serializeEnvFile(entries) {
41
+ const lines = [];
42
+ for (const { key, value } of entries) {
43
+ const needsQuoting = /[\s#"']/.test(value);
44
+ const serialized = needsQuoting ? `"${value.replace(/"/g, '\\"')}"` : value;
45
+ lines.push(`${key}=${serialized}`);
46
+ }
47
+ return lines.join('\n') + '\n';
48
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * SecretStore implementation backed by a .env file.
3
+ *
4
+ * Default location: ~/.slicc/secrets.env
5
+ * Override via SLICC_SECRETS_FILE env var.
6
+ *
7
+ * File is created with mode 0600 if it doesn't exist.
8
+ */
9
+ import type { Secret, SecretEntry, SecretStore } from './types.js';
10
+ export declare class EnvSecretStore implements SecretStore {
11
+ private readonly filePath;
12
+ constructor(filePath?: string);
13
+ get(name: string): Secret | null;
14
+ set(name: string, value: string, domains: string[]): void;
15
+ delete(name: string): void;
16
+ list(): SecretEntry[];
17
+ private readEntries;
18
+ private writeEntries;
19
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * SecretStore implementation backed by a .env file.
3
+ *
4
+ * Default location: ~/.slicc/secrets.env
5
+ * Override via SLICC_SECRETS_FILE env var.
6
+ *
7
+ * File is created with mode 0600 if it doesn't exist.
8
+ */
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import { parseEnvFile, serializeEnvFile } from './env-file.js';
13
+ const DOMAINS_SUFFIX = '_DOMAINS';
14
+ const DEFAULT_PATH = resolve(homedir(), '.slicc', 'secrets.env');
15
+ const FILE_MODE = 0o600;
16
+ export class EnvSecretStore {
17
+ filePath;
18
+ constructor(filePath) {
19
+ this.filePath = filePath ?? process.env['SLICC_SECRETS_FILE'] ?? DEFAULT_PATH;
20
+ }
21
+ get(name) {
22
+ const entries = this.readEntries();
23
+ const valueEntry = entries.find((e) => e.key === name);
24
+ const domainsEntry = entries.find((e) => e.key === name + DOMAINS_SUFFIX);
25
+ if (!valueEntry || !domainsEntry)
26
+ return null;
27
+ const domains = parseDomains(domainsEntry.value);
28
+ if (domains.length === 0)
29
+ return null;
30
+ return { name, value: valueEntry.value, domains };
31
+ }
32
+ set(name, value, domains) {
33
+ if (domains.length === 0) {
34
+ throw new Error(`Secret "${name}" must have at least one authorized domain`);
35
+ }
36
+ const entries = this.readEntries();
37
+ const domainsKey = name + DOMAINS_SUFFIX;
38
+ upsertEntry(entries, name, value);
39
+ upsertEntry(entries, domainsKey, domains.join(','));
40
+ this.writeEntries(entries);
41
+ }
42
+ delete(name) {
43
+ const entries = this.readEntries();
44
+ const domainsKey = name + DOMAINS_SUFFIX;
45
+ const filtered = entries.filter((e) => e.key !== name && e.key !== domainsKey);
46
+ this.writeEntries(filtered);
47
+ }
48
+ list() {
49
+ const entries = this.readEntries();
50
+ const result = [];
51
+ for (const entry of entries) {
52
+ if (entry.key.endsWith(DOMAINS_SUFFIX))
53
+ continue;
54
+ const domainsEntry = entries.find((e) => e.key === entry.key + DOMAINS_SUFFIX);
55
+ if (!domainsEntry)
56
+ continue; // no _DOMAINS → skip (rejected)
57
+ const domains = parseDomains(domainsEntry.value);
58
+ if (domains.length > 0) {
59
+ result.push({ name: entry.key, domains });
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ // -- internal helpers --
65
+ readEntries() {
66
+ if (!existsSync(this.filePath))
67
+ return [];
68
+ const content = readFileSync(this.filePath, 'utf-8');
69
+ return parseEnvFile(content);
70
+ }
71
+ writeEntries(entries) {
72
+ const dir = dirname(this.filePath);
73
+ if (!existsSync(dir)) {
74
+ mkdirSync(dir, { recursive: true });
75
+ }
76
+ const content = serializeEnvFile(entries);
77
+ writeFileSync(this.filePath, content, { mode: FILE_MODE });
78
+ // Ensure permissions even if file already existed
79
+ try {
80
+ chmodSync(this.filePath, FILE_MODE);
81
+ }
82
+ catch {
83
+ // chmod may fail on some platforms (Windows); best-effort
84
+ }
85
+ }
86
+ }
87
+ function parseDomains(value) {
88
+ return value
89
+ .split(',')
90
+ .map((d) => d.trim())
91
+ .filter((d) => d.length > 0);
92
+ }
93
+ function upsertEntry(entries, key, value) {
94
+ const idx = entries.findIndex((e) => e.key === key);
95
+ if (idx >= 0) {
96
+ entries[idx] = { key, value };
97
+ }
98
+ else {
99
+ entries.push({ key, value });
100
+ }
101
+ }
@@ -0,0 +1,5 @@
1
+ export type { Secret, SecretEntry, SecretStore } from './types.js';
2
+ export { EnvSecretStore } from './env-secret-store.js';
3
+ export { matchDomain, matchesDomains } from './domain-match.js';
4
+ export { parseEnvFile, serializeEnvFile } from './env-file.js';
5
+ export { SecretProxyManager, type MaskedSecret } from './proxy-manager.js';
@@ -0,0 +1,4 @@
1
+ export { EnvSecretStore } from './env-secret-store.js';
2
+ export { matchDomain, matchesDomains } from './domain-match.js';
3
+ export { parseEnvFile, serializeEnvFile } from './env-file.js';
4
+ export { SecretProxyManager } from './proxy-manager.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Secret masking engine for the node-server fetch proxy.
3
+ *
4
+ * This is a Node-specific copy of the core masking logic from
5
+ * packages/webapp/src/core/secret-masking.ts. Both must stay in sync.
6
+ *
7
+ * Uses Node's crypto.subtle (available since Node 15).
8
+ */
9
+ /**
10
+ * Produce a deterministic, format-preserving masked value.
11
+ */
12
+ export declare function mask(sessionId: string, secretName: string, realValue: string): Promise<string>;
13
+ export interface SecretPair {
14
+ realValue: string;
15
+ maskedValue: string;
16
+ }
17
+ /**
18
+ * Build a reusable scrubber function that replaces every occurrence
19
+ * of any `realValue` with its `maskedValue`.
20
+ */
21
+ export declare function buildScrubber(secrets: SecretPair[]): (text: string) => string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Secret masking engine for the node-server fetch proxy.
3
+ *
4
+ * This is a Node-specific copy of the core masking logic from
5
+ * packages/webapp/src/core/secret-masking.ts. Both must stay in sync.
6
+ *
7
+ * Uses Node's crypto.subtle (available since Node 15).
8
+ */
9
+ import { subtle } from 'node:crypto';
10
+ // ---------- Known token prefixes ----------
11
+ const KNOWN_PREFIXES = [
12
+ 'ghp_',
13
+ 'gho_',
14
+ 'ghu_',
15
+ 'ghs_',
16
+ 'ghr_',
17
+ 'github_pat_',
18
+ 'sk-',
19
+ 'pk-',
20
+ 'xoxb-',
21
+ 'xoxp-',
22
+ 'xoxa-',
23
+ 'xoxs-',
24
+ 'AKIA',
25
+ 'ABIA',
26
+ 'ACCA',
27
+ 'ASIA',
28
+ 'sk-ant-',
29
+ 'Bearer ',
30
+ ];
31
+ const SORTED_PREFIXES = [...KNOWN_PREFIXES].sort((a, b) => b.length - a.length);
32
+ function detectPrefix(value) {
33
+ for (const p of SORTED_PREFIXES) {
34
+ if (value.startsWith(p))
35
+ return p;
36
+ }
37
+ return '';
38
+ }
39
+ // ---------- HMAC-SHA256 ----------
40
+ async function hmacSha256(key, message) {
41
+ const enc = new TextEncoder();
42
+ const cryptoKey = await subtle.importKey('raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
43
+ const sig = await subtle.sign('HMAC', cryptoKey, enc.encode(message));
44
+ return new Uint8Array(sig);
45
+ }
46
+ function toHex(bytes) {
47
+ return Array.from(bytes)
48
+ .map((b) => b.toString(16).padStart(2, '0'))
49
+ .join('');
50
+ }
51
+ // ---------- Public API ----------
52
+ /**
53
+ * Produce a deterministic, format-preserving masked value.
54
+ */
55
+ export async function mask(sessionId, secretName, realValue) {
56
+ const prefix = detectPrefix(realValue);
57
+ const remainder = realValue.slice(prefix.length);
58
+ const hmac = await hmacSha256(sessionId + secretName, realValue);
59
+ let hex = toHex(hmac);
60
+ while (hex.length < remainder.length)
61
+ hex += hex;
62
+ const maskedRemainder = hex.slice(0, remainder.length);
63
+ return prefix + maskedRemainder;
64
+ }
65
+ /**
66
+ * Build a reusable scrubber function that replaces every occurrence
67
+ * of any `realValue` with its `maskedValue`.
68
+ */
69
+ export function buildScrubber(secrets) {
70
+ if (secrets.length === 0)
71
+ return (t) => t;
72
+ const sorted = [...secrets].sort((a, b) => b.realValue.length - a.realValue.length);
73
+ return (text) => {
74
+ let result = text;
75
+ for (const { realValue, maskedValue } of sorted) {
76
+ if (result.includes(realValue)) {
77
+ result = result.split(realValue).join(maskedValue);
78
+ }
79
+ }
80
+ return result;
81
+ };
82
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * SecretProxyManager — bridges EnvSecretStore with the masking engine
3
+ * for the fetch-proxy handler.
4
+ *
5
+ * On init: loads all secrets, generates session-scoped masked values,
6
+ * and builds lookup tables for fast replacement.
7
+ */
8
+ import { EnvSecretStore } from './env-secret-store.js';
9
+ export interface MaskedSecret {
10
+ name: string;
11
+ maskedValue: string;
12
+ realValue: string;
13
+ domains: string[];
14
+ }
15
+ export declare class SecretProxyManager {
16
+ private readonly store;
17
+ private readonly sessionId;
18
+ /** maskedValue → MaskedSecret */
19
+ private maskedToSecret;
20
+ /** Scrubber that replaces real→masked in response content */
21
+ private scrubber;
22
+ constructor(store?: EnvSecretStore, sessionId?: string);
23
+ /**
24
+ * Load secrets from the store and generate masked values.
25
+ * Call once on startup and again whenever secrets change.
26
+ */
27
+ reload(): Promise<void>;
28
+ /**
29
+ * Check if any secrets are loaded.
30
+ */
31
+ hasSecrets(): boolean;
32
+ /**
33
+ * Get all masked entries (name + maskedValue + domains) for env population.
34
+ */
35
+ getMaskedEntries(): Array<{
36
+ name: string;
37
+ maskedValue: string;
38
+ domains: string[];
39
+ }>;
40
+ /**
41
+ * Unmask a text blob: replace masked values with real values.
42
+ * Validates each secret against the target hostname.
43
+ *
44
+ * @returns { text, forbidden } — forbidden is set if a secret was blocked.
45
+ */
46
+ unmask(text: string, targetHostname: string): {
47
+ text: string;
48
+ forbidden?: {
49
+ secretName: string;
50
+ hostname: string;
51
+ };
52
+ };
53
+ /**
54
+ * Unmask body text: replace masked values with real values when domain matches.
55
+ * When domain does NOT match, the masked value is left as-is (not rejected).
56
+ * This is safe because the masked value is meaningless to the remote service —
57
+ * it's typically just conversation context sent to an LLM API.
58
+ */
59
+ unmaskBody(text: string, targetHostname: string): {
60
+ text: string;
61
+ };
62
+ /**
63
+ * Unmask headers in-place. Returns forbidden info if blocked.
64
+ */
65
+ unmaskHeaders(headers: Record<string, string>, targetHostname: string): {
66
+ forbidden?: {
67
+ secretName: string;
68
+ hostname: string;
69
+ };
70
+ };
71
+ /**
72
+ * Scrub real secret values from response text → masked values.
73
+ */
74
+ scrubResponse(text: string): string;
75
+ /**
76
+ * Scrub headers: replace real values with masked in header values.
77
+ */
78
+ scrubHeaders(headers: Headers): Record<string, string>;
79
+ /**
80
+ * Look up a secret by its masked value.
81
+ */
82
+ getByMaskedValue(maskedValue: string): MaskedSecret | undefined;
83
+ }