@wenrwa/marketplace-cli 0.8.5 → 0.8.7
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/__tests__/bridge-changes-report.test.js +25 -0
- package/dist/__tests__/bridge-process-cpu.test.d.ts +1 -0
- package/dist/__tests__/bridge-process-cpu.test.js +135 -0
- package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
- package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
- package/dist/__tests__/bridge-save-env.test.js +343 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
- package/dist/__tests__/bridge-server.test.js +9 -11
- package/dist/__tests__/secrets-egress.test.d.ts +1 -0
- package/dist/__tests__/secrets-egress.test.js +237 -0
- package/dist/__tests__/secrets-redact.test.d.ts +1 -0
- package/dist/__tests__/secrets-redact.test.js +318 -0
- package/dist/bridge/changesReport.js +10 -3
- package/dist/bridge/contextWriter.js +5 -1
- package/dist/bridge/envSaver.d.ts +63 -0
- package/dist/bridge/envSaver.js +256 -0
- package/dist/bridge/hiveopSettings.js +5 -0
- package/dist/bridge/launchSupervisor.js +4 -1
- package/dist/bridge/localContextWriter.js +6 -2
- package/dist/bridge/paneClient.d.ts +32 -0
- package/dist/bridge/paneClient.js +30 -0
- package/dist/bridge/processCpu.d.ts +29 -0
- package/dist/bridge/processCpu.js +160 -0
- package/dist/bridge/realPty.js +6 -1
- package/dist/bridge/relayClient.d.ts +9 -18
- package/dist/bridge/relayClient.js +32 -7
- package/dist/bridge/repoEnv.d.ts +34 -0
- package/dist/bridge/repoEnv.js +186 -0
- package/dist/bridge/server.js +69 -26
- package/dist/bridge/sessionManager.d.ts +10 -0
- package/dist/bridge/sessionManager.js +33 -8
- package/dist/bridge/workGraphPointer.d.ts +25 -0
- package/dist/bridge/workGraphPointer.js +36 -1
- package/dist/commands/bridge.js +8 -0
- package/dist/secrets/console.d.ts +4 -0
- package/dist/secrets/console.js +34 -0
- package/dist/secrets/redact.d.ts +79 -0
- package/dist/secrets/redact.js +637 -0
- package/hooks/opencode-plugin.mjs +55 -2
- package/hooks/pre-tool-use.js +96 -0
- package/package.json +1 -1
|
@@ -489,13 +489,14 @@ describe('bridge WebSocket server', () => {
|
|
|
489
489
|
});
|
|
490
490
|
// ── lws-07 (ADR-0033 port): attach carries cols/rows; replay frames say snapshot:true ────
|
|
491
491
|
describe('attach size + replay snapshot flag', () => {
|
|
492
|
-
// Re-attaching on the SAME connection
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
492
|
+
// Re-attaching on the SAME connection supersedes its own prior
|
|
493
|
+
// attachment, but the bridge no longer tells a socket it was taken over
|
|
494
|
+
// by itself (phantom "attached elsewhere", 2026-10-17) — the replay is
|
|
495
|
+
// the next frame.
|
|
496
496
|
const nextReplay = async (q) => {
|
|
497
|
-
const
|
|
498
|
-
|
|
497
|
+
const f = await q.next();
|
|
498
|
+
expect(f.t).toBe('replay');
|
|
499
|
+
return f;
|
|
499
500
|
};
|
|
500
501
|
it('attach with cols/rows resizes before replay (size differs from tracked geometry); replay marks snapshot:true', async () => {
|
|
501
502
|
const { ws, q } = await openAndAuth(handle.port);
|
|
@@ -649,12 +650,9 @@ describe('bridge WebSocket server — attach deadline (twr-16)', () => {
|
|
|
649
650
|
const opened = await q.next();
|
|
650
651
|
// A size differing from the tracked geometry enters the resize→settle
|
|
651
652
|
// branch the deadline stalls in. Re-attaching on the SAME connection
|
|
652
|
-
//
|
|
653
|
-
// `takeover` frame precedes the error on this socket — pull both and
|
|
654
|
-
// match by type (same convention as the size-handling tests above).
|
|
653
|
+
// sends no self-takeover, so the error is the next frame.
|
|
655
654
|
ws.send(JSON.stringify({ t: 'attach', sessionId: opened.sessionId, cols: 120, rows: 40 }));
|
|
656
|
-
const
|
|
657
|
-
const err = [a, b].find((f) => f.t === 'error');
|
|
655
|
+
const err = await q.next();
|
|
658
656
|
expect(err).toMatchObject({ t: 'error', sessionId: opened.sessionId });
|
|
659
657
|
expect(String(err.message)).toContain('stage=settle');
|
|
660
658
|
ws.close();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
/**
|
|
37
|
+
* Secret masking at every egress of the bridge (2026-10-17): the live sink,
|
|
38
|
+
* the attach replay, peek, the bridge's console, and the agent guards
|
|
39
|
+
* (Claude Code PreToolUse hook, OpenCode plugin) plus the standing rule in
|
|
40
|
+
* the managed context. Tokens are assembled at runtime (no literal secrets).
|
|
41
|
+
*/
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const child_process_1 = require("child_process");
|
|
46
|
+
const ts = __importStar(require("typescript"));
|
|
47
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
48
|
+
const redact_1 = require("../secrets/redact");
|
|
49
|
+
const console_1 = require("../secrets/console");
|
|
50
|
+
const hiveopSettings_1 = require("../bridge/hiveopSettings");
|
|
51
|
+
const workGraphPointer_1 = require("../bridge/workGraphPointer");
|
|
52
|
+
const contextWriter_1 = require("../bridge/contextWriter");
|
|
53
|
+
const localContextWriter_1 = require("../bridge/localContextWriter");
|
|
54
|
+
const UUID = ['ef1bd6d5', '7de1', '4da2', 'b722', '4414d9461a4e'].join('-');
|
|
55
|
+
const NPM = ['npm', '_', 'aB3cD4eF5gH6iJ7kL8mN9pQ2rS1tU0vWxYz'].join('');
|
|
56
|
+
const ROOT = path.join(__dirname, '..', '..');
|
|
57
|
+
class FakePty {
|
|
58
|
+
written = [];
|
|
59
|
+
dataCb = null;
|
|
60
|
+
exitCb = null;
|
|
61
|
+
write(d) { this.written.push(d); }
|
|
62
|
+
resize() { }
|
|
63
|
+
kill() { }
|
|
64
|
+
onData(cb) { this.dataCb = cb; }
|
|
65
|
+
onExit(cb) { this.exitCb = cb; }
|
|
66
|
+
emit(d) { this.dataCb?.(d); }
|
|
67
|
+
exit(code) { this.exitCb?.(code); }
|
|
68
|
+
}
|
|
69
|
+
function manager() {
|
|
70
|
+
const ptys = [];
|
|
71
|
+
const factory = { spawn: () => { const p = new FakePty(); ptys.push(p); return p; } };
|
|
72
|
+
const sm = new sessionManager_1.SessionManager(factory, { launchGate: false, settleMs: 0, delay: async () => { } });
|
|
73
|
+
return { sm, ptys };
|
|
74
|
+
}
|
|
75
|
+
describe('SessionManager — every output egress is masked', () => {
|
|
76
|
+
it('live frames, the attach replay and peek never carry the raw value; the PTY still gets real input', async () => {
|
|
77
|
+
const { sm, ptys } = manager();
|
|
78
|
+
const id = sm.open({ cols: 120, rows: 30 });
|
|
79
|
+
const frames = [];
|
|
80
|
+
const handle = await sm.attach(id, (f) => { if (f.t === 'data')
|
|
81
|
+
frames.push(f.data); });
|
|
82
|
+
// The owner screenshot: split mid-value across two PTY reads.
|
|
83
|
+
ptys[0].emit(`$ RAILWAY_API_TOKEN=${UUID.slice(0, 13)}`);
|
|
84
|
+
ptys[0].emit(`${UUID.slice(13)} $R up --ci\r\n`);
|
|
85
|
+
ptys[0].emit(`\x1b[32m${NPM}\x1b[0m\r\n`);
|
|
86
|
+
const live = frames.join('');
|
|
87
|
+
expect(live).not.toContain(UUID.slice(0, 8));
|
|
88
|
+
expect(live).not.toContain(UUID.slice(13));
|
|
89
|
+
expect(live).not.toContain(NPM.slice(4, 14));
|
|
90
|
+
expect(live).toContain('RAILWAY_API_TOKEN=****');
|
|
91
|
+
expect(live).toContain('$R up --ci');
|
|
92
|
+
const peek = (await sm.peek(id)) ?? '';
|
|
93
|
+
expect(peek).toContain('RAILWAY_API_TOKEN=');
|
|
94
|
+
expect(peek).not.toContain(UUID.slice(0, 8));
|
|
95
|
+
expect(peek).not.toContain(NPM.slice(4, 14));
|
|
96
|
+
const again = await sm.attach(id, () => { });
|
|
97
|
+
expect(again.replay).toContain('$R up --ci');
|
|
98
|
+
expect(again.replay).not.toContain(UUID.slice(0, 8));
|
|
99
|
+
// Input is never touched: the agent gets the real bytes.
|
|
100
|
+
again.write(`export TOKEN=${UUID}\r`);
|
|
101
|
+
expect(ptys[0].written).toContain(`export TOKEN=${UUID}\r`);
|
|
102
|
+
handle.detach();
|
|
103
|
+
});
|
|
104
|
+
it('a held tail is released (masked) when the process exits', async () => {
|
|
105
|
+
const { sm, ptys } = manager();
|
|
106
|
+
const id = sm.open({ cols: 80, rows: 24 });
|
|
107
|
+
const frames = [];
|
|
108
|
+
await sm.attach(id, (f) => { if (f.t === 'data')
|
|
109
|
+
frames.push(f.data); });
|
|
110
|
+
ptys[0].emit(`password: ${UUID}`);
|
|
111
|
+
ptys[0].exit(0);
|
|
112
|
+
expect(frames.join('')).toBe(`password: ${'*'.repeat(UUID.length)}`);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('console redaction (bridge.log / fly logs)', () => {
|
|
116
|
+
it('masks every console line and keeps the rest', () => {
|
|
117
|
+
const lines = [];
|
|
118
|
+
const push = (...a) => { lines.push(String(a[0])); };
|
|
119
|
+
const fake = { log: push, info: push, warn: push, error: push, debug: push };
|
|
120
|
+
(0, console_1.installConsoleRedaction)(fake);
|
|
121
|
+
(0, console_1.installConsoleRedaction)(fake); // idempotent
|
|
122
|
+
fake.error('[bridge] claude exited:', `GITHUB_TOKEN=${UUID}`, { n: 1 });
|
|
123
|
+
expect(lines).toHaveLength(1);
|
|
124
|
+
expect(lines[0]).toContain('[bridge] claude exited: GITHUB_TOKEN=****');
|
|
125
|
+
expect(lines[0]).not.toContain(UUID.slice(0, 8));
|
|
126
|
+
expect(lines[0]).toContain('{ n: 1 }');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
/** Compile redact.ts to a temp CommonJS file so child processes (hook, plugin) can load it. */
|
|
130
|
+
function compiledDetector() {
|
|
131
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'secrets-'));
|
|
132
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'secrets', 'redact.ts'), 'utf8');
|
|
133
|
+
const out = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } });
|
|
134
|
+
const file = path.join(dir, 'redact.js');
|
|
135
|
+
fs.writeFileSync(file, out.outputText);
|
|
136
|
+
return file;
|
|
137
|
+
}
|
|
138
|
+
describe('Claude Code PreToolUse secret guard (hooks/pre-tool-use.js)', () => {
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
140
|
+
const hook = require(path.join(ROOT, 'hooks', 'pre-tool-use.js'));
|
|
141
|
+
const bash = (command) => ({ tool_name: 'Bash', tool_input: { command } });
|
|
142
|
+
it('blocks a literal secret and never repeats it in the message', () => {
|
|
143
|
+
const v = hook.decide(bash(`RAILWAY_API_TOKEN=${UUID} $R up --ci`), redact_1.containsSecret, {});
|
|
144
|
+
expect(v.block).toBe(true);
|
|
145
|
+
expect(v.message).toMatch(/environment variable|\.env/);
|
|
146
|
+
expect(v.message).not.toContain(UUID);
|
|
147
|
+
});
|
|
148
|
+
it.each([
|
|
149
|
+
'RAILWAY_API_TOKEN="$RAILWAY_TOKEN" railway up --ci',
|
|
150
|
+
'curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com',
|
|
151
|
+
'git push origin HEAD:main',
|
|
152
|
+
`echo agent ${UUID} started`,
|
|
153
|
+
])('lets %s run', (cmd) => {
|
|
154
|
+
expect(hook.decide(bash(cmd), redact_1.containsSecret, {}).block).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
it('only guards Bash, honours the opt-out, and fails open on a missing or throwing detector', () => {
|
|
157
|
+
expect(hook.decide({ tool_name: 'Edit', tool_input: { command: `TOKEN=${UUID}` } }, redact_1.containsSecret, {}).block).toBe(false);
|
|
158
|
+
expect(hook.decide(bash(`TOKEN=${UUID}`), redact_1.containsSecret, { HIVEOP_SECRET_GUARD: '0' }).block).toBe(false);
|
|
159
|
+
expect(hook.decide(bash(`TOKEN=${UUID}`), null, {}).block).toBe(false);
|
|
160
|
+
expect(hook.decide(bash(`TOKEN=${UUID}`), () => { throw new Error('x'); }, {}).block).toBe(false);
|
|
161
|
+
expect(hook.decide(null, redact_1.containsSecret, {}).block).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
it('as a real process: exit 2 + message on a secret, exit 0 otherwise, exit 0 with no detector', () => {
|
|
164
|
+
const mod = compiledDetector();
|
|
165
|
+
const run = (ev, env) => (0, child_process_1.spawnSync)(process.execPath, [path.join(ROOT, 'hooks', 'pre-tool-use.js')], {
|
|
166
|
+
input: JSON.stringify(ev), env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf8',
|
|
167
|
+
});
|
|
168
|
+
const blocked = run(bash(`export GH_TOKEN=${NPM}`), { HIVEOP_SECRETS_MODULE: mod });
|
|
169
|
+
expect(blocked.status).toBe(2);
|
|
170
|
+
expect(blocked.stderr).toContain('HiveOp blocked this command');
|
|
171
|
+
expect(blocked.stderr).not.toContain(NPM.slice(4));
|
|
172
|
+
expect(run(bash('ls -la'), { HIVEOP_SECRETS_MODULE: mod }).status).toBe(0);
|
|
173
|
+
expect(run(bash(`TOKEN=${UUID}`), { HIVEOP_SECRETS_MODULE: '/nope.js' }).status).toBe(0);
|
|
174
|
+
expect(run('not json', { HIVEOP_SECRETS_MODULE: mod }).status).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
it('is registered for Bash in the local settings file, and the hosted copy is byte-identical', () => {
|
|
177
|
+
const settings = JSON.parse((0, hiveopSettings_1.buildHiveOpSettingsContent)('/h'));
|
|
178
|
+
expect(settings.hooks.PreToolUse).toEqual([
|
|
179
|
+
{ matcher: 'Bash', hooks: [{ type: 'command', command: `node "${path.join('/h', 'pre-tool-use.js')}"` }] },
|
|
180
|
+
]);
|
|
181
|
+
expect(fs.readFileSync(path.join(ROOT, 'hooks', 'pre-tool-use.js'), 'utf8'))
|
|
182
|
+
.toBe(fs.readFileSync(path.join(ROOT, '..', 'infra', 'terminal-image', 'hooks', 'pre-tool-use.js'), 'utf8'));
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe('OpenCode secret guard (hooks/opencode-plugin.mjs tool.execute.before)', () => {
|
|
186
|
+
function runBefore(command, env) {
|
|
187
|
+
const script = `
|
|
188
|
+
const { HiveOp } = await import(process.argv[1]);
|
|
189
|
+
const h = await HiveOp({});
|
|
190
|
+
try { await h['tool.execute.before']({ tool: 'bash', sessionID: 's' }, { args: { command: process.argv[2] } }); console.log(JSON.stringify({ threw: false, message: '' })); }
|
|
191
|
+
catch (e) { console.log(JSON.stringify({ threw: true, message: String(e && e.message) })); }`;
|
|
192
|
+
const out = (0, child_process_1.execFileSync)(process.execPath, ['--input-type=module', '-e', script, path.join(ROOT, 'hooks', 'opencode-plugin.mjs'), command], {
|
|
193
|
+
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf8',
|
|
194
|
+
});
|
|
195
|
+
return JSON.parse(out.trim().split('\n').pop());
|
|
196
|
+
}
|
|
197
|
+
it('blocks a bash call with a literal secret and lets a $VAR reference through', () => {
|
|
198
|
+
const mod = compiledDetector();
|
|
199
|
+
const blocked = runBefore(`RAILWAY_API_TOKEN=${UUID} railway up`, { HIVEOP_SECRETS_MODULE: mod });
|
|
200
|
+
expect(blocked.threw).toBe(true);
|
|
201
|
+
expect(blocked.message).toContain('HiveOp blocked this command');
|
|
202
|
+
expect(blocked.message).not.toContain(UUID);
|
|
203
|
+
expect(runBefore('RAILWAY_API_TOKEN="$RAILWAY_TOKEN" railway up', { HIVEOP_SECRETS_MODULE: mod }).threw).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
it('fails open without a detector and honours the opt-out', () => {
|
|
206
|
+
expect(runBefore(`TOKEN=${UUID}`, { HIVEOP_SECRETS_MODULE: '/nope.js' }).threw).toBe(false);
|
|
207
|
+
expect(runBefore(`TOKEN=${UUID}`, { HIVEOP_SECRETS_MODULE: compiledDetector(), HIVEOP_SECRET_GUARD: '0' }).threw).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('standing rule in the managed context', () => {
|
|
211
|
+
it('rides the pointer block when there is one, and never creates one on its own', () => {
|
|
212
|
+
expect((0, workGraphPointer_1.withSecretsSection)([])).toEqual([]);
|
|
213
|
+
expect((0, workGraphPointer_1.withSecretsSection)(['## Work graph'])).toEqual(['## Work graph', workGraphPointer_1.SECRETS_POINTER]);
|
|
214
|
+
expect(workGraphPointer_1.SECRETS_POINTER.split('\n').length).toBeLessThanOrEqual(2);
|
|
215
|
+
expect(workGraphPointer_1.SECRETS_POINTER).toMatch(/Never print, echo or cat/);
|
|
216
|
+
});
|
|
217
|
+
it('both lanes carry it in the file the agent reads', () => {
|
|
218
|
+
const files = new Map();
|
|
219
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: '', workGraphAvailable: true }, {
|
|
220
|
+
reposBaseDir: '/data/repos',
|
|
221
|
+
exists: (p) => files.has(p),
|
|
222
|
+
readFile: (p) => files.get(p) ?? '',
|
|
223
|
+
writeFile: (p, c) => { files.set(p, c); },
|
|
224
|
+
mkdirp: () => { },
|
|
225
|
+
});
|
|
226
|
+
expect(files.get('/data/repos/ws-7/CLAUDE.md')).toContain(workGraphPointer_1.SECRETS_POINTER);
|
|
227
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'secrets-ctx-'));
|
|
228
|
+
(0, localContextWriter_1.writeLocalContext)({ projectRoot: root, content: '', workGraphAvailable: true }, {
|
|
229
|
+
exists: (p) => fs.existsSync(p),
|
|
230
|
+
readFile: (p) => fs.readFileSync(p, 'utf-8'),
|
|
231
|
+
mkdirp: (d) => fs.mkdirSync(d, { recursive: true }),
|
|
232
|
+
writeFileAtomic: (p, c) => fs.writeFileSync(p, c),
|
|
233
|
+
remove: (p) => fs.rmSync(p, { force: true }),
|
|
234
|
+
});
|
|
235
|
+
expect(fs.readFileSync(path.join(root, localContextWriter_1.LOCAL_CONTEXT_RELATIVE_PATH), 'utf-8')).toContain(workGraphPointer_1.SECRETS_POINTER);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Secret masking (cli/src/secrets/redact.ts). Every token here is assembled at
|
|
5
|
+
* runtime from split pieces so no literal credential-shaped string sits in the
|
|
6
|
+
* repo (GitHub push protection, and our own guard hook, would both trip).
|
|
7
|
+
*/
|
|
8
|
+
const redact_1 = require("../secrets/redact");
|
|
9
|
+
/** Deterministic alnum body with letters and digits. */
|
|
10
|
+
function body(n, seed = 7) {
|
|
11
|
+
const abc = 'aB3cD4eF5gH6iJ7kL8mN9pQ2rS1tU0vWxYz';
|
|
12
|
+
let s = '';
|
|
13
|
+
let x = seed;
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
x = (x * 31 + 17) % 9973;
|
|
16
|
+
s += abc[x % abc.length];
|
|
17
|
+
}
|
|
18
|
+
return s;
|
|
19
|
+
}
|
|
20
|
+
const j = (...p) => p.join('');
|
|
21
|
+
const UUID = 'ef1bd6d5-7de1-4da2-b722-4414d9461a4e';
|
|
22
|
+
/** [label, token] — each must be masked keeping its first 4 chars. */
|
|
23
|
+
const PREFIXED = [
|
|
24
|
+
['npm', j('npm', '_', body(36))],
|
|
25
|
+
['anthropic', j('sk', '-ant-api03-', body(40))],
|
|
26
|
+
['openrouter', j('sk', '-or-v1-', body(48, 3).toLowerCase().replace(/[^a-z0-9]/g, '1'))],
|
|
27
|
+
['openai', j('sk', '-proj-', body(40, 11))],
|
|
28
|
+
['github classic', j('gh', 'p_', body(36, 5))],
|
|
29
|
+
['github oauth', j('gh', 'o_', body(36, 6))],
|
|
30
|
+
['github server', j('gh', 's_', body(36, 8))],
|
|
31
|
+
['github fine-grained', j('github', '_pat_', '11ABCDE', body(50, 9))],
|
|
32
|
+
['gitlab', j('gl', 'pat-', body(20, 12))],
|
|
33
|
+
['slack', j('xo', 'xb-', '1234567890-', body(24, 13))],
|
|
34
|
+
['stripe secret', j('sk', '_live_', body(24, 14))],
|
|
35
|
+
['stripe restricted', j('rk', '_live_', body(24, 15))],
|
|
36
|
+
['aws id', j('AK', 'IA', 'Z7Q2', 'K4M9P3L8N6R1')],
|
|
37
|
+
['google', j('AI', 'za', body(35, 16))],
|
|
38
|
+
['jwt', j('ey', 'JhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0', '.', body(30, 17))],
|
|
39
|
+
['hiveop api key', j('wm', '_sk_', body(32, 18))],
|
|
40
|
+
['hiveop connect token', j('wm', '_ct_', body(32, 19))],
|
|
41
|
+
];
|
|
42
|
+
describe('findSecrets / redact — detector', () => {
|
|
43
|
+
it('masks the owner screenshot line, keeping the name and the rest of the command', () => {
|
|
44
|
+
const line = `RAILWAY_API_TOKEN=${UUID} $R up --ci`;
|
|
45
|
+
expect((0, redact_1.redact)(line)).toBe(`RAILWAY_API_TOKEN=${'*'.repeat(UUID.length)} $R up --ci`);
|
|
46
|
+
});
|
|
47
|
+
it.each(PREFIXED)('masks a %s token, keeping its first 4 chars', (_label, tok) => {
|
|
48
|
+
const out = (0, redact_1.redact)(`token is ${tok} ok`);
|
|
49
|
+
expect(out).toBe(`token is ${tok.slice(0, 4)}${'*'.repeat(tok.length - 4)} ok`);
|
|
50
|
+
});
|
|
51
|
+
it('leaves Stripe publishable keys alone', () => {
|
|
52
|
+
const pk = j('pk', '_live_', body(24));
|
|
53
|
+
expect((0, redact_1.redact)(`key ${pk}`)).toBe(`key ${pk}`);
|
|
54
|
+
expect((0, redact_1.redact)(`STRIPE_PUBLISHABLE_KEY=${pk}`)).toBe(`STRIPE_PUBLISHABLE_KEY=${pk}`);
|
|
55
|
+
});
|
|
56
|
+
it.each([
|
|
57
|
+
[`export GITHUB_TOKEN=${body(30)}`, 'export GITHUB_TOKEN='],
|
|
58
|
+
[`DATABASE_PASSWORD="${body(16)}"`, 'DATABASE_PASSWORD="'],
|
|
59
|
+
[`api_key: ${body(20)}`, 'api_key: '],
|
|
60
|
+
[`{"apiKey": "${body(20)}"}`, '{"apiKey": "'],
|
|
61
|
+
[`{\\"client_secret\\":\\"${body(24)}\\"}`, '{\\"client_secret\\":\\"'],
|
|
62
|
+
[`aws_secret_access_key = ${body(40)}`, 'aws_secret_access_key = '],
|
|
63
|
+
[`railway login --token ${UUID}`, 'railway login --token '],
|
|
64
|
+
[`cli --api-key=${body(24)}`, 'cli --api-key='],
|
|
65
|
+
[`x-api-key: ${body(24)}`, 'x-api-key: '],
|
|
66
|
+
[`curl -H 'Authorization: Bearer ${body(32)}' https://x`, `curl -H 'Authorization: Bearer `],
|
|
67
|
+
[`Authorization: Basic dXNlcjpwYXNzd29yZA==`, 'Authorization: Basic '],
|
|
68
|
+
[`Bearer ${body(40)}`, 'Bearer '],
|
|
69
|
+
[`DB_PASS=${body(14)}`, 'DB_PASS='],
|
|
70
|
+
[`SESSION_SECRET=${UUID}`, 'SESSION_SECRET='],
|
|
71
|
+
])('masks the value of %s', (line, keep) => {
|
|
72
|
+
const out = (0, redact_1.redact)(line);
|
|
73
|
+
expect(out.startsWith(keep)).toBe(true);
|
|
74
|
+
expect(out.length).toBe(line.length);
|
|
75
|
+
expect(out.slice(keep.length)).toMatch(/^\*{8,}/);
|
|
76
|
+
});
|
|
77
|
+
it('masks a URL password, keeping the user and host', () => {
|
|
78
|
+
const pw = body(16);
|
|
79
|
+
expect((0, redact_1.redact)(`postgres://app:${pw}@db.internal:5432/x`)).toBe(`postgres://app:${'*'.repeat(16)}@db.internal:5432/x`);
|
|
80
|
+
});
|
|
81
|
+
it('masks a private key body but keeps its BEGIN/END lines', () => {
|
|
82
|
+
const lines = [0, 1, 2].map((i) => body(64, 20 + i));
|
|
83
|
+
const pem = ['-----BEGIN RSA PRIVATE KEY-----', ...lines, '-----END RSA PRIVATE KEY-----', 'after ' + 'plain'].join('\n');
|
|
84
|
+
const out = (0, redact_1.redact)(pem);
|
|
85
|
+
for (const l of lines)
|
|
86
|
+
expect(out).not.toContain(l.slice(0, 12));
|
|
87
|
+
expect(out).toContain('-----BEGIN RSA PRIVATE KEY-----');
|
|
88
|
+
expect(out).toContain('-----END RSA PRIVATE KEY-----');
|
|
89
|
+
expect(out).toContain('after plain');
|
|
90
|
+
expect(out.length).toBe(pem.length);
|
|
91
|
+
});
|
|
92
|
+
it.each([
|
|
93
|
+
['a git sha alone', 'commit 3f9a2b7c1d4e5f60718293a4b5c6d7e8f9012345'],
|
|
94
|
+
['a uuid alone', `agent ${UUID} started`],
|
|
95
|
+
['a uuid after a non-secret name', `SESSION_ID=${UUID}`],
|
|
96
|
+
['base64 in ordinary output', `data: ${Buffer.from('hello world, this is just normal text 12345').toString('base64')}`],
|
|
97
|
+
['an env var reference', 'RAILWAY_API_TOKEN=$RAILWAY_TOKEN railway up'],
|
|
98
|
+
['a braced reference', 'export API_KEY=${API_KEY}'],
|
|
99
|
+
['a command substitution', 'TOKEN=$(cat ~/.token)'],
|
|
100
|
+
['an angle-bracket placeholder', 'GITHUB_TOKEN=<your-token-here>'],
|
|
101
|
+
['a your-key-here placeholder', 'OPENAI_API_KEY=your-api-key-here-123'],
|
|
102
|
+
['an x placeholder', 'API_KEY=xxxxxxxxxxxxxxxx'],
|
|
103
|
+
['a star placeholder (already masked)', 'API_KEY=****************'],
|
|
104
|
+
['a boolean', 'AUTH_ENABLED=true'],
|
|
105
|
+
['a file path', 'GOOGLE_APPLICATION_CREDENTIALS=/home/me/creds-2024.json'],
|
|
106
|
+
['a relative path', 'SSL_KEY=./certs/server-2024.key'],
|
|
107
|
+
['a plain URL', 'AUTH_URL=https://auth.example.com/v1/callback'],
|
|
108
|
+
['a token count', 'max_tokens=409600000000'],
|
|
109
|
+
['usage counters', '"input_tokens": 123456789012'],
|
|
110
|
+
['a cache key', 'cacheKey: build-2024-abcdef123456'],
|
|
111
|
+
['a sort key in JSON', '"sortKey": "item-000123456789"'],
|
|
112
|
+
['code reading an env var', 'const apiKey = process.env.OPENAI_API_KEY;'],
|
|
113
|
+
['a dotted config path', 'apiKey: config.stripe.secretKey2'],
|
|
114
|
+
['an ISO timestamp', 'TOKEN_EXPIRES_AT=2026-10-17T12:00:00Z'],
|
|
115
|
+
['a version', 'AUTH_LIB_VERSION=12.4.1234567'],
|
|
116
|
+
['a package-lock integrity', '"integrity": "sha512-Abc123Def456Ghi789Jkl012Mno345Pqr678=="'],
|
|
117
|
+
['the word token in prose', 'the token 1234 was refreshed for user alice'],
|
|
118
|
+
['a Bearer with a variable', 'curl -H "Authorization: Bearer $TOKEN" https://api'],
|
|
119
|
+
['a URL with a variable password', 'git clone https://x-access-token:${GH_TOKEN}@github.com/o/r'],
|
|
120
|
+
['a kebab-case word after sk-', 'sk-learn-is-a-python-machine-learning-library'],
|
|
121
|
+
['an AWS example key', j('AK', 'IA', 'IOSFODNN7EXAMPLE')],
|
|
122
|
+
['a key id', `--key-id ${UUID}`],
|
|
123
|
+
['a docker digest', 'image@sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'],
|
|
124
|
+
])('does not mask %s', (_label, line) => {
|
|
125
|
+
expect((0, redact_1.redact)(line)).toBe(line);
|
|
126
|
+
expect((0, redact_1.containsSecret)(line)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
it('names: secret-ish segments only', () => {
|
|
129
|
+
for (const n of ['RAILWAY_API_TOKEN', 'apiKey', 'x-api-key', 'DB_PASSWORD', 'client_secret', 'authToken', 'PRIVATE_KEY', 'stripeSecretKey', 'NPM_AUTH']) {
|
|
130
|
+
expect((0, redact_1.isSecretName)(n)).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
for (const n of ['MONKEY', 'keyboard', 'author', 'key', 'cacheKey', 'max_tokens', 'SESSION_ID', 'PUBLIC_KEY', 'NEXT_PUBLIC_API_KEY', 'private']) {
|
|
133
|
+
expect((0, redact_1.isSecretName)(n)).toBe(false);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
it('is idempotent — masked output has nothing left to mask', () => {
|
|
137
|
+
const line = `RAILWAY_API_TOKEN=${UUID} and ${PREFIXED[0][1]}`;
|
|
138
|
+
const once = (0, redact_1.redact)(line);
|
|
139
|
+
expect((0, redact_1.redact)(once)).toBe(once);
|
|
140
|
+
expect((0, redact_1.findSecrets)(once)).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('redact — escape sequences', () => {
|
|
144
|
+
it('masks a token painted in several colours and keeps every escape intact', () => {
|
|
145
|
+
const tok = PREFIXED[0][1];
|
|
146
|
+
const painted = `\x1b[32m${tok.slice(0, 10)}\x1b[1;33m${tok.slice(10)}\x1b[0m`;
|
|
147
|
+
const out = (0, redact_1.redact)(painted);
|
|
148
|
+
expect(out).toBe(`\x1b[32m${tok.slice(0, 4)}${'*'.repeat(6)}\x1b[1;33m${'*'.repeat(tok.length - 10)}\x1b[0m`);
|
|
149
|
+
});
|
|
150
|
+
it('masks a secret inside an OSC window title', () => {
|
|
151
|
+
const out = (0, redact_1.redact)(`\x1b]0;RAILWAY_API_TOKEN=${UUID}\x07rest`);
|
|
152
|
+
expect(out).toBe(`\x1b]0;RAILWAY_API_TOKEN=${'*'.repeat(36)}\x07rest`);
|
|
153
|
+
});
|
|
154
|
+
it('cursor moves separate words, so a name and a value on different cells never join', () => {
|
|
155
|
+
const line = `API_TOKEN\x1b[5;1H=${UUID}`;
|
|
156
|
+
expect((0, redact_1.redact)(line)).toBe(line);
|
|
157
|
+
});
|
|
158
|
+
it('a TUI frame: box drawing and widths survive, only the value is masked', () => {
|
|
159
|
+
const frame = [
|
|
160
|
+
'\x1b[?2026h\x1b[1;1H\x1b[38;2;215;119;87m╭──────────────────────────────╮\x1b[39m',
|
|
161
|
+
`\x1b[2;1H│ \x1b[1m⏺ Bash\x1b[22m(RAILWAY_API_TOKEN=${UUID} $R up --ci) │`,
|
|
162
|
+
'\x1b[3;1H╰──────────────────────────────╯\x1b[?2026l',
|
|
163
|
+
].join('');
|
|
164
|
+
const out = (0, redact_1.redact)(frame);
|
|
165
|
+
expect(out).not.toContain(UUID.slice(0, 8));
|
|
166
|
+
expect(out.length).toBe(frame.length);
|
|
167
|
+
expect(out).toContain('$R up --ci) │');
|
|
168
|
+
expect(out).toContain('\x1b[?2026h');
|
|
169
|
+
expect(out).toContain('\x1b[?2026l');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
/** Run `chunks` through a stream redactor with NO idle flush between them. */
|
|
173
|
+
function streamAll(chunks, opts = {}) {
|
|
174
|
+
let out = '';
|
|
175
|
+
const r = (0, redact_1.createStreamRedactor)({ emit: (s) => { out += s; }, setTimer: () => 1, clearTimer: () => { }, ...opts });
|
|
176
|
+
for (const c of chunks)
|
|
177
|
+
r.push(c);
|
|
178
|
+
r.flush();
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
const FIXTURES = [
|
|
182
|
+
`$ RAILWAY_API_TOKEN=${UUID} $R up --ci\r\n`,
|
|
183
|
+
`found ${PREFIXED[0][1]} in .npmrc\r\n`,
|
|
184
|
+
`\x1b[32m${PREFIXED[4][1]}\x1b[0m done\r\n`,
|
|
185
|
+
`curl -H 'Authorization: Bearer ${body(32)}' https://api.example.com\r\n`,
|
|
186
|
+
`postgres://app:${body(16)}@db:5432/x\r\n`,
|
|
187
|
+
`\x1b]0;OPENAI_API_KEY=${body(24)}\x07\x1b[2;3Hhello`,
|
|
188
|
+
['-----BEGIN OPENSSH PRIVATE KEY-----', body(64, 1), body(64, 2), body(20, 3), '-----END OPENSSH PRIVATE KEY-----', 'ok'].join('\r\n'),
|
|
189
|
+
`{"api_key": "${body(28)}", "user": "alice"}\n`,
|
|
190
|
+
`token: ${PREFIXED[14][1]}\n`,
|
|
191
|
+
`ls -la\r\n\x1b[1;34msrc\x1b[0m README.md package.json\r\n`,
|
|
192
|
+
];
|
|
193
|
+
describe('createStreamRedactor — chunk splits', () => {
|
|
194
|
+
it.each(FIXTURES.map((f, i) => [i, f]))('fixture %i: every 2-way split matches the one-shot result', (_i, fixture) => {
|
|
195
|
+
const expected = (0, redact_1.redact)(fixture);
|
|
196
|
+
for (let i = 1; i < fixture.length; i++) {
|
|
197
|
+
expect(streamAll([fixture.slice(0, i), fixture.slice(i)], { maskTrailing: false })).toBe(expected);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
it.each(FIXTURES.map((f, i) => [i, f]))('fixture %i: 3-way splits match the one-shot result', (_i, fixture) => {
|
|
201
|
+
const expected = (0, redact_1.redact)(fixture);
|
|
202
|
+
const step = Math.max(1, Math.floor(fixture.length / 25));
|
|
203
|
+
for (let a = 1; a < fixture.length; a += step) {
|
|
204
|
+
for (let b = a + 1; b < fixture.length; b += step) {
|
|
205
|
+
expect(streamAll([fixture.slice(0, a), fixture.slice(a, b), fixture.slice(b)], { maskTrailing: false })).toBe(expected);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
it('byte-at-a-time delivery still masks (a paste echoed one char per read)', () => {
|
|
210
|
+
for (const fixture of FIXTURES) {
|
|
211
|
+
expect(streamAll(fixture.split(''), { maskTrailing: false })).toBe((0, redact_1.redact)(fixture));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
it('escape sequences split at every point pass through intact', () => {
|
|
215
|
+
const s = 'a\x1b[38;2;1;2;3mb\x1b]0;title\x07c\x1b[?25ld\x1bPq#0;2;0;0;0\x1b\\e';
|
|
216
|
+
for (let i = 1; i < s.length; i++)
|
|
217
|
+
expect(streamAll([s.slice(0, i), s.slice(i)])).toBe(s);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('createStreamRedactor — holding and idle release', () => {
|
|
221
|
+
function harness() {
|
|
222
|
+
let out = '';
|
|
223
|
+
let fire = null;
|
|
224
|
+
const r = (0, redact_1.createStreamRedactor)({
|
|
225
|
+
emit: (s) => { out += s; },
|
|
226
|
+
setTimer: (cb) => { fire = cb; return 1; },
|
|
227
|
+
clearTimer: () => { fire = null; },
|
|
228
|
+
});
|
|
229
|
+
return { r, out: () => out, idle: () => { const f = fire; fire = null; f?.(); }, armed: () => fire !== null };
|
|
230
|
+
}
|
|
231
|
+
it('plain typing echo is never held', () => {
|
|
232
|
+
const h = harness();
|
|
233
|
+
h.r.push('l');
|
|
234
|
+
h.r.push('s');
|
|
235
|
+
expect(h.out()).toBe('ls');
|
|
236
|
+
expect(h.armed()).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
it('holds a value that may still be growing, then masks it whole when the rest arrives', () => {
|
|
239
|
+
const h = harness();
|
|
240
|
+
h.r.push(`RAILWAY_API_TOKEN=${UUID.slice(0, 10)}`);
|
|
241
|
+
expect(h.out()).toBe('RAILWAY_API_TOKEN=');
|
|
242
|
+
expect(h.armed()).toBe(true);
|
|
243
|
+
h.r.push(`${UUID.slice(10)} $R up\r\n`);
|
|
244
|
+
expect(h.out()).toBe(`RAILWAY_API_TOKEN=${'*'.repeat(36)} $R up\r\n`);
|
|
245
|
+
});
|
|
246
|
+
it('releases a held tail on idle', () => {
|
|
247
|
+
const h = harness();
|
|
248
|
+
h.r.push('api_key: ab');
|
|
249
|
+
expect(h.out()).toBe('api_key: ');
|
|
250
|
+
h.idle();
|
|
251
|
+
expect(h.out()).toBe('api_key: ab');
|
|
252
|
+
});
|
|
253
|
+
it('a typed secret: after the idle release, the rest of the value is still masked', () => {
|
|
254
|
+
const h = harness();
|
|
255
|
+
h.r.push(`export GITHUB_TOKEN=${UUID.slice(0, 2)}`);
|
|
256
|
+
h.idle();
|
|
257
|
+
for (const ch of UUID.slice(2)) {
|
|
258
|
+
h.r.push(ch);
|
|
259
|
+
h.idle();
|
|
260
|
+
}
|
|
261
|
+
h.r.push('\r\n');
|
|
262
|
+
const out = h.out();
|
|
263
|
+
expect(out.startsWith(`export GITHUB_TOKEN=${UUID.slice(0, 2)}`)).toBe(true);
|
|
264
|
+
expect(out).not.toContain(UUID.slice(2, 8));
|
|
265
|
+
expect(out.length).toBe(`export GITHUB_TOKEN=${UUID}\r\n`.length);
|
|
266
|
+
});
|
|
267
|
+
it('a prefixed token split by a slow read: only the first 4 chars are ever visible', () => {
|
|
268
|
+
const tok = PREFIXED[0][1];
|
|
269
|
+
const h = harness();
|
|
270
|
+
h.r.push(tok.slice(0, 12));
|
|
271
|
+
h.idle();
|
|
272
|
+
h.r.push(tok.slice(12) + ' end');
|
|
273
|
+
expect(h.out()).toBe(`${tok.slice(0, 4)}${'*'.repeat(tok.length - 4)} end`);
|
|
274
|
+
});
|
|
275
|
+
it('flush on exit releases everything', () => {
|
|
276
|
+
const h = harness();
|
|
277
|
+
h.r.push('password: ');
|
|
278
|
+
h.r.push('hunter2hunter2');
|
|
279
|
+
h.r.flush();
|
|
280
|
+
expect(h.out()).toBe(`password: ${'*'.repeat(14)}`);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
describe('performance', () => {
|
|
284
|
+
it('a 1MB TUI-like stream with no secrets passes through unchanged and fast', () => {
|
|
285
|
+
const line = '\x1b[2;1H│ \x1b[38;5;245mReading src/components/Button.tsx (42 lines)\x1b[39m │ 3f9a2b7c1d4e │\r\n';
|
|
286
|
+
const chunk = line.repeat(Math.ceil(16384 / line.length));
|
|
287
|
+
const t0 = Date.now();
|
|
288
|
+
let out = '';
|
|
289
|
+
const r = (0, redact_1.createStreamRedactor)({ emit: (s) => { out += s; }, setTimer: () => 1, clearTimer: () => { } });
|
|
290
|
+
for (let i = 0; i < 64; i++)
|
|
291
|
+
r.push(chunk);
|
|
292
|
+
r.flush();
|
|
293
|
+
const ms = Date.now() - t0;
|
|
294
|
+
expect(out).toBe(chunk.repeat(64));
|
|
295
|
+
expect(ms).toBeLessThan(1500);
|
|
296
|
+
});
|
|
297
|
+
// Each of these once took 20-45s one-shot (a pattern restarting inside a
|
|
298
|
+
// long run of its own leading class). Linear now; pinned so it stays so.
|
|
299
|
+
it.each([
|
|
300
|
+
['base64 blob', require('crypto').randomBytes(150000).toString('base64')],
|
|
301
|
+
['identifier run', 'a_'.repeat(100000)],
|
|
302
|
+
['repeated NAME=', 'TOKEN='.repeat(35000)],
|
|
303
|
+
['repeated name: ', 'api_key: '.repeat(25000)],
|
|
304
|
+
['repeated URL', 'http://a:b'.repeat(20000)],
|
|
305
|
+
['colour per cell', '\x1b[31mx'.repeat(40000)],
|
|
306
|
+
['unterminated key', '-----BEGIN RSA PRIVATE KEY-----\n' + ('A'.repeat(64) + '\n').repeat(3000)],
|
|
307
|
+
])('adversarial input stays linear: %s', (_label, text) => {
|
|
308
|
+
const t0 = Date.now();
|
|
309
|
+
expect((0, redact_1.redact)(text).length).toBe(text.length);
|
|
310
|
+
expect(streamAll(text.match(/[\s\S]{1,4096}/g)).length).toBe(text.length);
|
|
311
|
+
expect(Date.now() - t0).toBeLessThan(3000);
|
|
312
|
+
});
|
|
313
|
+
it('redactInSlices matches redact on large text', async () => {
|
|
314
|
+
const big = (`line ${'x'.repeat(50)} RAILWAY_API_TOKEN=${UUID} ok\n`).repeat(2000);
|
|
315
|
+
expect(await (0, redact_1.redactInSlices)(big, 4096)).toBe((0, redact_1.redact)(big));
|
|
316
|
+
expect(await (0, redact_1.redactInSlices)(big, 4096)).not.toContain(UUID);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -68,6 +68,7 @@ exports.buildChangesReport = buildChangesReport;
|
|
|
68
68
|
const crypto_1 = require("crypto");
|
|
69
69
|
const os = __importStar(require("os"));
|
|
70
70
|
const path = __importStar(require("path"));
|
|
71
|
+
const redact_1 = require("../secrets/redact");
|
|
71
72
|
const DEFAULT_MAX_PATCH_BYTES = 64_000;
|
|
72
73
|
const GENERATED_BASENAMES = new Set([
|
|
73
74
|
'package-lock.json',
|
|
@@ -96,15 +97,21 @@ class GitStepError extends Error {
|
|
|
96
97
|
super(label);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Cuts `patch` to the last full line that fits within `maxBytes` (UTF-8 byte
|
|
102
|
+
* length), then masks any secret in it (2026-10-17: a committed .env line is
|
|
103
|
+
* as visible in the Changes panel as in a terminal). Masking is
|
|
104
|
+
* length-preserving ASCII, so the byte budget is unchanged; `contentHash` is
|
|
105
|
+
* still taken from the unmasked full patch by the caller.
|
|
106
|
+
*/
|
|
100
107
|
function truncatePatch(patch, maxBytes) {
|
|
101
108
|
const buf = Buffer.from(patch, 'utf8');
|
|
102
109
|
if (buf.length <= maxBytes)
|
|
103
|
-
return { text: patch, truncated: false };
|
|
110
|
+
return { text: (0, redact_1.redact)(patch), truncated: false };
|
|
104
111
|
const slice = buf.subarray(0, maxBytes).toString('utf8');
|
|
105
112
|
const lastNewline = slice.lastIndexOf('\n');
|
|
106
113
|
const text = lastNewline >= 0 ? slice.slice(0, lastNewline + 1) : '';
|
|
107
|
-
return { text, truncated: true };
|
|
114
|
+
return { text: (0, redact_1.redact)(text), truncated: true };
|
|
108
115
|
}
|
|
109
116
|
function parseNameStatus(stdout) {
|
|
110
117
|
const entries = [];
|
|
@@ -176,12 +176,16 @@ function writeContextFile(input, claudeMdPath, seedPath, workspaceRoot, deps) {
|
|
|
176
176
|
const browser = (0, workGraphPointer_1.browserSection)(input);
|
|
177
177
|
if (browser)
|
|
178
178
|
pointers.push(browser);
|
|
179
|
+
const keys = (0, workGraphPointer_1.keysSection)(input);
|
|
180
|
+
if (keys)
|
|
181
|
+
pointers.push(keys);
|
|
182
|
+
const sections = (0, workGraphPointer_1.withSecretsSection)(pointers);
|
|
179
183
|
// wmr-07: a pointers-only refresh keeps the region's existing file content
|
|
180
184
|
// rather than replacing it with the (absent) `content` argument.
|
|
181
185
|
const fileContent = input.pointersOnly
|
|
182
186
|
? splitRegion(existingRegion).rest
|
|
183
187
|
: (input.content ?? '').trim();
|
|
184
|
-
const block = pointerBlock(
|
|
188
|
+
const block = pointerBlock(sections);
|
|
185
189
|
const parts = [block, fileContent].filter((part) => part.length > 0);
|
|
186
190
|
const content = parts.join('\n\n');
|
|
187
191
|
const isEmpty = content.trim().length === 0;
|