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.
- package/README.md +7 -0
- package/dist/node-server/index.js +80 -5
- package/dist/node-server/runtime-flags.d.ts +2 -0
- package/dist/node-server/runtime-flags.js +14 -0
- package/dist/node-server/secrets/domain-match.d.ts +20 -0
- package/dist/node-server/secrets/domain-match.js +37 -0
- package/dist/node-server/secrets/env-file.d.ts +23 -0
- package/dist/node-server/secrets/env-file.js +48 -0
- package/dist/node-server/secrets/env-secret-store.d.ts +19 -0
- package/dist/node-server/secrets/env-secret-store.js +101 -0
- package/dist/node-server/secrets/index.d.ts +5 -0
- package/dist/node-server/secrets/index.js +4 -0
- package/dist/node-server/secrets/masking.d.ts +21 -0
- package/dist/node-server/secrets/masking.js +82 -0
- package/dist/node-server/secrets/proxy-manager.d.ts +83 -0
- package/dist/node-server/secrets/proxy-manager.js +141 -0
- package/dist/node-server/secrets/types.d.ts +29 -0
- package/dist/node-server/secrets/types.js +1 -0
- package/dist/ui/assets/{anthropic-C4vxs4g1.js → anthropic-Ctm13Y4z.js} +1 -1
- package/dist/ui/assets/{azure-openai-responses-CQGUi04X.js → azure-openai-responses-C0Fcf6OA.js} +1 -1
- package/dist/ui/assets/{cdp-Ch8e9w_F.js → cdp-B4yzwpE0.js} +2 -2
- package/dist/ui/assets/{dist-D_HlTg57.js → dist-B3JYrjq2.js} +2 -2
- package/dist/ui/assets/{es-BB5XVW8l.js → es-DlWKrNhH.js} +1 -1
- package/dist/ui/assets/{google-DlWVI40D.js → google-D_JFv6N-.js} +1 -1
- package/dist/ui/assets/{google-gemini-cli-Cij1vUE6.js → google-gemini-cli-BjwSjH_H.js} +1 -1
- package/dist/ui/assets/{google-shared-D0cKpK1A.js → google-shared-1MEdugC3.js} +1 -1
- package/dist/ui/assets/{google-vertex-qWV5CXzF.js → google-vertex-MzEI1-me.js} +1 -1
- package/dist/ui/assets/{index-BgzzSeH7.js → index-D0ffClMg.js} +122 -93
- package/dist/ui/assets/lick-manager-IFElAmqB.js +1 -0
- package/dist/ui/assets/{magick-wasm-JayMw6yo.js → magick-wasm-cJ5msdlA.js} +1 -1
- package/dist/ui/assets/{mistral-DtJjgVHB.js → mistral-DcpebUMg.js} +1 -1
- package/dist/ui/assets/{openai-codex-responses-_MfKWbO7.js → openai-codex-responses-g4roCtlP.js} +1 -1
- package/dist/ui/assets/{openai-completions-_u7NXbnG.js → openai-completions-CTyIshQV.js} +1 -1
- package/dist/ui/assets/{openai-responses-3JWIezrg.js → openai-responses-DEGSelVp.js} +1 -1
- package/dist/ui/assets/{openai-responses-shared-CXRfi1gl.js → openai-responses-shared-DQA10qMf.js} +1 -1
- package/dist/ui/assets/{provider-settings-Q4v0fqFS.js → provider-settings-8zmqtsZN.js} +1 -1
- package/dist/ui/assets/{provider-settings-CVTu49GU.js → provider-settings-DvGHKYX6.js} +4 -4
- package/dist/ui/assets/providers-C3FxylOY.js +1 -0
- package/dist/ui/assets/{pyodide-BB8mNECZ.js → pyodide-CDyS-YOW.js} +2 -2
- package/dist/ui/assets/secret-env-Dy73TZRP.js +1 -0
- package/dist/ui/assets/secret-masking-CfbASE5x.js +1 -0
- package/dist/ui/assets/shell-NUxCukI4.js +1 -0
- package/dist/ui/assets/sprinkle-renderer-Cv5hoBK5.js +1 -0
- package/dist/ui/assets/{sql-wasm-qCsadHEF.js → sql-wasm-Cx-UtEsl.js} +1 -1
- package/dist/ui/index.html +4 -4
- package/dist/ui/packages/webapp/index.html +4 -4
- package/package.json +1 -1
- package/dist/ui/assets/lick-manager-DRxmzPRX.js +0 -1
- package/dist/ui/assets/providers-CtyOAYB2.js +0 -1
- package/dist/ui/assets/shell-CmUsf9_D.js +0 -1
- package/dist/ui/assets/sprinkle-renderer-DpWOA1rr.js +0 -1
- /package/dist/ui/assets/{__vite-browser-external-CmLpjmp4.js → __vite-browser-external-fVHSRdZ9.js} +0 -0
- /package/dist/ui/assets/{addon-fit-CnTv21Qe.js → addon-fit-CJBYijzN.js} +0 -0
- /package/dist/ui/assets/{bsh-watchdog-DUSt_vXs.js → bsh-watchdog-Dfc2Sii6.js} +0 -0
- /package/dist/ui/assets/{dist-CwgfYnJh.js → dist-B_UNq8pG.js} +0 -0
- /package/dist/ui/assets/{github-copilot-headers-BNWb_OCE.js → github-copilot-headers-BpGzQFlb.js} +0 -0
- /package/dist/ui/assets/{hash-CsofV07h.js → hash-K0cSe8jy.js} +0 -0
- /package/dist/ui/assets/{lick-manager-proxy-CogaR9kt.js → lick-manager-proxy-BsVSy3SO.js} +0 -0
- /package/dist/ui/assets/{oauth-service-BoyG8vZW.js → oauth-service-t9Y4KdOX.js} +0 -0
- /package/dist/ui/assets/{offscreen-client-B8UaOgaJ.js → offscreen-client-BIdj4gf1.js} +0 -0
- /package/dist/ui/assets/{pdfjs-DkB4gfOb.js → pdfjs-DqPUb7Z_.js} +0 -0
- /package/dist/ui/assets/{sanitize-unicode-Dw-JFaZE.js → sanitize-unicode-BA6bNBBh.js} +0 -0
- /package/dist/ui/assets/{src-CHtOpyP_.js → src-CgSXdUB7.js} +0 -0
- /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
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
+
}
|