happyskills 1.11.0 → 1.12.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/CHANGELOG.md +14 -0
- package/package.json +9 -9
- package/src/commands/publish.js +34 -8
- package/src/commands/publish.test.js +30 -2
- package/src/commands/release.js +6 -2
- package/src/config/limits.js +4 -2
- package/src/config/paths.js +19 -4
- package/src/config/paths.test.js +15 -0
- package/src/engine/archive_installer.js +35 -14
- package/src/engine/archive_installer.test.js +52 -1
- package/src/ui/output.js +16 -8
- package/src/ui/output.test.js +23 -1
- package/src/utils/scrub_secrets.js +8 -5
- package/src/utils/scrub_secrets.test.js +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.12.0] - 2026-06-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add a `--visibility <private|workspace|public>` flag to `publish` and `release`, making a workspace-visible first publish a single step. Previously the publish path could only emit `public` (via `--public`) or `private`, so shipping a skill as `workspace`-visible (discoverable and installable by every member of the owning workspace, but not public) required a second `visibility` command after publishing. The flag is honored on first publish only — later publishes preserve the existing visibility — and `--visibility` takes precedence over `--public`. `--public` is retained as a backward-compatible shorthand for `--visibility public`; the never-functional `--private` flag (always a silent no-op, since private is the default) is dropped from the documented examples.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- Cap `.tar.gz` decompression during install — enforce per-file size, total decompressed size, and entry-count limits mid-stream (and reject an oversized download by `Content-Length` before buffering), so a malicious highly-compressible archive can't inflate unbounded and exhaust the consumer's memory/disk. (SSRF-01/SUP-04)
|
|
19
|
+
- Scrub C0/C1 terminal control bytes (`ESC`, `CR`, `BEL`, `DEL`, …) from server-supplied strings at the print boundary, so a crafted skill name or API message can't spoof or attack the terminal. (NEW-C2)
|
|
20
|
+
- Guard server-derived install paths with an `assert_within` check, so a `..` or absolute skill name can't escape the skills directory on install. (NEW-C3)
|
|
21
|
+
- Expand the secret scrubber to redact GitHub tokens (`gh[pousr]_`), AWS access-key IDs (`AKIA`/`ASIA…`), and `postgres://`/`postgresql://` connection strings before feedback context is sent to the backend. (DATA-04)
|
|
22
|
+
- Pin all CLI runtime dependencies to exact locked versions for a reproducible published package. (SUP-09)
|
|
23
|
+
|
|
10
24
|
## [1.11.0] - 2026-06-12
|
|
11
25
|
|
|
12
26
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Package manager for AI agent skills",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"node": ">=22.0.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@jimp/core": "
|
|
47
|
-
"@jimp/js-jpeg": "
|
|
48
|
-
"@jimp/js-png": "
|
|
49
|
-
"@jimp/plugin-resize": "
|
|
50
|
-
"node-diff3": "
|
|
51
|
-
"puffy-core": "
|
|
52
|
-
"semver": "
|
|
53
|
-
"tar-stream": "
|
|
46
|
+
"@jimp/core": "1.6.1",
|
|
47
|
+
"@jimp/js-jpeg": "1.6.1",
|
|
48
|
+
"@jimp/js-png": "1.6.1",
|
|
49
|
+
"@jimp/plugin-resize": "1.6.1",
|
|
50
|
+
"node-diff3": "3.2.0",
|
|
51
|
+
"puffy-core": "1.3.1",
|
|
52
|
+
"semver": "7.7.4",
|
|
53
|
+
"tar-stream": "3.1.8"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"dotenv": "^17.2.4"
|
package/src/commands/publish.js
CHANGED
|
@@ -36,16 +36,40 @@ Arguments:
|
|
|
36
36
|
Options:
|
|
37
37
|
--bump <type> Auto-bump version before publishing (patch, minor, major)
|
|
38
38
|
--workspace <slug> Target workspace (overrides lock file owner)
|
|
39
|
-
--
|
|
39
|
+
--visibility <value> Visibility on first publish: private (default), workspace, or public
|
|
40
|
+
--public Shorthand for --visibility public
|
|
40
41
|
--force Bypass divergence check (may overwrite remote changes)
|
|
41
42
|
|
|
42
43
|
Aliases: pub
|
|
43
44
|
|
|
45
|
+
Visibility (set on first publish; preserved on later publishes):
|
|
46
|
+
private (default) Only people you explicitly grant access to.
|
|
47
|
+
workspace Every member of the owning workspace can find and install it. Not public.
|
|
48
|
+
public Anyone — listed in the public registry.
|
|
49
|
+
|
|
44
50
|
Examples:
|
|
45
51
|
happyskills publish my-skill
|
|
46
52
|
happyskills publish deploy-aws --bump patch
|
|
53
|
+
happyskills publish team-deploy --workspace acme --visibility workspace
|
|
47
54
|
happyskills pub my-skill --workspace myorg`
|
|
48
55
|
|
|
56
|
+
const VALID_VISIBILITIES = ['public', 'private', 'workspace']
|
|
57
|
+
|
|
58
|
+
// Resolve the target visibility from flags. --visibility wins when present
|
|
59
|
+
// (validated against the closed set); --public stays as a backward-compatible
|
|
60
|
+
// shorthand; the default is private. Visibility only takes effect on FIRST
|
|
61
|
+
// publish — later publishes preserve whatever the registry already has.
|
|
62
|
+
const resolve_visibility = (flags) => {
|
|
63
|
+
if (flags.visibility) {
|
|
64
|
+
if (!VALID_VISIBILITIES.includes(flags.visibility)) {
|
|
65
|
+
throw new UsageError(`Invalid visibility "${flags.visibility}". Must be one of: ${VALID_VISIBILITIES.join(', ')}.`)
|
|
66
|
+
}
|
|
67
|
+
return flags.visibility
|
|
68
|
+
}
|
|
69
|
+
if (flags.public) return 'public'
|
|
70
|
+
return 'private'
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
const choose_workspace = (workspaces, preferred) => {
|
|
50
74
|
if (preferred) {
|
|
51
75
|
const found = workspaces.find(w => w.slug === preferred)
|
|
@@ -136,7 +160,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
136
160
|
|
|
137
161
|
const spinner = create_spinner('Preparing to publish...')
|
|
138
162
|
|
|
139
|
-
const visibility = args.flags
|
|
163
|
+
const visibility = resolve_visibility(args.flags)
|
|
140
164
|
|
|
141
165
|
let owner = args.flags.workspace
|
|
142
166
|
if (!owner) {
|
|
@@ -348,11 +372,12 @@ const schema = {
|
|
|
348
372
|
input: {
|
|
349
373
|
positional: [ { name: 'skill', required: true, type: 'string', description: 'Name of the installed skill' } ],
|
|
350
374
|
flags: [
|
|
351
|
-
{ name: 'bump',
|
|
352
|
-
{ name: 'workspace',
|
|
353
|
-
{ name: '
|
|
354
|
-
{ name: '
|
|
355
|
-
{ name: '
|
|
375
|
+
{ name: 'bump', type: 'string', default: undefined, description: 'Auto-bump version before publishing (patch, minor, major)' },
|
|
376
|
+
{ name: 'workspace', type: 'string', default: undefined, description: 'Target workspace (overrides lock file owner)' },
|
|
377
|
+
{ name: 'visibility', type: 'string', default: undefined, description: 'Visibility on first publish: private (default), workspace, or public' },
|
|
378
|
+
{ name: 'public', type: 'boolean', default: false, description: 'Shorthand for --visibility public' },
|
|
379
|
+
{ name: 'force', type: 'boolean', default: false, description: 'Bypass divergence check (may overwrite remote changes)' },
|
|
380
|
+
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
356
381
|
],
|
|
357
382
|
},
|
|
358
383
|
output: {
|
|
@@ -375,7 +400,8 @@ const schema = {
|
|
|
375
400
|
examples: [
|
|
376
401
|
'happyskills publish my-skill',
|
|
377
402
|
'happyskills publish deploy-aws --bump patch',
|
|
403
|
+
'happyskills publish team-deploy --workspace acme --visibility workspace',
|
|
378
404
|
],
|
|
379
405
|
}
|
|
380
406
|
|
|
381
|
-
module.exports = { run, schema }
|
|
407
|
+
module.exports = { run, schema, resolve_visibility }
|
|
@@ -2,14 +2,15 @@ const { describe, it } = require('node:test')
|
|
|
2
2
|
const assert = require('node:assert')
|
|
3
3
|
const fs = require('node:fs')
|
|
4
4
|
const path = require('node:path')
|
|
5
|
+
const { resolve_visibility } = require('./publish')
|
|
5
6
|
|
|
6
7
|
describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () => {
|
|
7
8
|
const src = fs.readFileSync(path.join(__dirname, 'publish.js'), 'utf8')
|
|
8
9
|
|
|
9
10
|
it('declares `visibility` before passing it to validate_dependencies', () => {
|
|
10
|
-
const decl_idx = src.search(/const\s+visibility\s*=\s*
|
|
11
|
+
const decl_idx = src.search(/const\s+visibility\s*=\s*resolve_visibility\(/)
|
|
11
12
|
const use_idx = src.search(/validate_dependencies\([\s\S]*?visibility/)
|
|
12
|
-
assert.notStrictEqual(decl_idx, -1, '`const visibility =
|
|
13
|
+
assert.notStrictEqual(decl_idx, -1, '`const visibility = resolve_visibility(...)` declaration not found')
|
|
13
14
|
assert.notStrictEqual(use_idx, -1, '`validate_dependencies(... visibility ...)` call not found')
|
|
14
15
|
assert.ok(
|
|
15
16
|
decl_idx < use_idx,
|
|
@@ -19,3 +20,30 @@ describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () =>
|
|
|
19
20
|
)
|
|
20
21
|
})
|
|
21
22
|
})
|
|
23
|
+
|
|
24
|
+
describe('publish.js — resolve_visibility', () => {
|
|
25
|
+
it('defaults to private when no visibility flags are given', () => {
|
|
26
|
+
assert.strictEqual(resolve_visibility({}), 'private')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('maps --public to public (backward-compatible shorthand)', () => {
|
|
30
|
+
assert.strictEqual(resolve_visibility({ public: true }), 'public')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('accepts --visibility workspace', () => {
|
|
34
|
+
assert.strictEqual(resolve_visibility({ visibility: 'workspace' }), 'workspace')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('accepts --visibility private and --visibility public', () => {
|
|
38
|
+
assert.strictEqual(resolve_visibility({ visibility: 'private' }), 'private')
|
|
39
|
+
assert.strictEqual(resolve_visibility({ visibility: 'public' }), 'public')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('lets --visibility win over --public when both are present', () => {
|
|
43
|
+
assert.strictEqual(resolve_visibility({ visibility: 'workspace', public: true }), 'workspace')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('throws a usage error on an unknown visibility value', () => {
|
|
47
|
+
assert.throws(() => resolve_visibility({ visibility: 'team' }), /Invalid visibility/)
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/commands/release.js
CHANGED
|
@@ -34,13 +34,15 @@ Options:
|
|
|
34
34
|
--no-bump Refuse to bump; require disk to be already ahead
|
|
35
35
|
--changelog-from <auto|file> Source for the new CHANGELOG entry (default: read from CHANGELOG.md)
|
|
36
36
|
--workspace <slug> Target workspace
|
|
37
|
-
--
|
|
37
|
+
--visibility <value> Visibility on first publish: private (default), workspace, or public
|
|
38
|
+
--public Shorthand for --visibility public
|
|
38
39
|
--dry-run Validate + check status, do not mutate
|
|
39
40
|
--json Output as JSON
|
|
40
41
|
|
|
41
42
|
Examples:
|
|
42
43
|
happyskills release my-skill --workspace acme --json
|
|
43
44
|
happyskills release my-skill --bump patch --workspace acme --json
|
|
45
|
+
happyskills release team-deploy --workspace acme --visibility workspace --json
|
|
44
46
|
happyskills release my-skill --no-bump --json # disk is already ahead`
|
|
45
47
|
|
|
46
48
|
const envelope_error = (code_str, message, extra = {}) => ({ error: { code: code_str, message, ...extra } })
|
|
@@ -384,6 +386,7 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
384
386
|
|
|
385
387
|
const { spawn } = require('child_process')
|
|
386
388
|
const publish_args = [path.resolve(__dirname, '../../bin/happyskills.js'), 'publish', skill_name, '--workspace', resolved_workspace, '--json']
|
|
389
|
+
if (args.flags.visibility) publish_args.push('--visibility', args.flags.visibility)
|
|
387
390
|
if (args.flags.public) publish_args.push('--public')
|
|
388
391
|
if (args.flags.force) publish_args.push('--force')
|
|
389
392
|
|
|
@@ -494,7 +497,8 @@ const schema = {
|
|
|
494
497
|
{ name: 'no-bump', type: 'boolean', default: false, description: 'Refuse to bump; require disk to already be ahead' },
|
|
495
498
|
{ name: 'changelog-from', type: 'string', default: undefined, description: 'Source for the new CHANGELOG entry (auto or file path)' },
|
|
496
499
|
{ name: 'workspace', type: 'string', default: undefined, description: 'Target workspace slug' },
|
|
497
|
-
{ name: '
|
|
500
|
+
{ name: 'visibility', type: 'string', default: undefined, description: 'Visibility on first publish: private (default), workspace, or public' },
|
|
501
|
+
{ name: 'public', type: 'boolean', default: false, description: 'Shorthand for --visibility public' },
|
|
498
502
|
{ name: 'dry-run', type: 'boolean', default: false, description: 'Validate and check status without mutating' },
|
|
499
503
|
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
500
504
|
],
|
package/src/config/limits.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// validation will be rejected by the API.
|
|
4
4
|
|
|
5
5
|
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB per file
|
|
6
|
-
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 // 1 MB per skill bundle
|
|
6
|
+
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 // 1 MB per skill bundle (also the decompressed-archive cap — SUP-04)
|
|
7
|
+
const MAX_FILE_COUNT = 1000 // max entries per archive (zip-bomb / DoS guard; real skills have <100 files) — SUP-04
|
|
7
8
|
|
|
8
9
|
module.exports = {
|
|
9
10
|
MAX_FILE_SIZE,
|
|
10
|
-
MAX_TOTAL_SIZE
|
|
11
|
+
MAX_TOTAL_SIZE,
|
|
12
|
+
MAX_FILE_COUNT
|
|
11
13
|
}
|
package/src/config/paths.js
CHANGED
|
@@ -22,6 +22,18 @@ const skills_dir = (global = false, project_root) => {
|
|
|
22
22
|
return global ? global_skills_dir() : project_skills_dir(project_root)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// NEW-C3 (spec 260603-02 §4 Tier 3). The install-dir name is server-derived; a
|
|
26
|
+
// `..` or absolute component would let it escape the skills directory and write
|
|
27
|
+
// elsewhere on disk. Defense-in-depth: confirm the joined path stays under base.
|
|
28
|
+
const assert_within = (base, full) => {
|
|
29
|
+
const r_base = path.resolve(base)
|
|
30
|
+
const r_full = path.resolve(full)
|
|
31
|
+
if (r_full !== r_base && !r_full.startsWith(r_base + path.sep)) {
|
|
32
|
+
throw new Error(`Unsafe install path "${full}": escapes the skills directory ${base}`)
|
|
33
|
+
}
|
|
34
|
+
return full
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const tmp_dir = (base_skills_dir) => path.join(base_skills_dir, '.tmp')
|
|
26
38
|
|
|
27
39
|
const install_lock_path = (base_skills_dir) => path.join(base_skills_dir, '.install.lock')
|
|
@@ -32,7 +44,7 @@ const lock_root = (is_global, project_root) => {
|
|
|
32
44
|
|
|
33
45
|
const lock_file_path = (project_root = process.cwd()) => path.join(project_root, 'skills-lock.json')
|
|
34
46
|
|
|
35
|
-
const skill_install_dir = (base_skills_dir, name) => path.join(base_skills_dir, name)
|
|
47
|
+
const skill_install_dir = (base_skills_dir, name) => assert_within(base_skills_dir, path.join(base_skills_dir, name))
|
|
36
48
|
|
|
37
49
|
const find_project_root = (start_dir = process.cwd()) => path.resolve(start_dir)
|
|
38
50
|
|
|
@@ -41,8 +53,10 @@ const agent_skills_dir = (agent, global = false, project_root) => {
|
|
|
41
53
|
return path.join(project_root || process.cwd(), agent.skills_dir)
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
const agent_skill_install_dir = (agent, global, project_root, skill_name) =>
|
|
45
|
-
|
|
56
|
+
const agent_skill_install_dir = (agent, global, project_root, skill_name) => {
|
|
57
|
+
const base = agent_skills_dir(agent, global, project_root)
|
|
58
|
+
return assert_within(base, path.join(base, skill_name))
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
module.exports = {
|
|
48
62
|
home_dir,
|
|
@@ -59,5 +73,6 @@ module.exports = {
|
|
|
59
73
|
skill_install_dir,
|
|
60
74
|
find_project_root,
|
|
61
75
|
agent_skills_dir,
|
|
62
|
-
agent_skill_install_dir
|
|
76
|
+
agent_skill_install_dir,
|
|
77
|
+
assert_within
|
|
63
78
|
}
|
package/src/config/paths.test.js
CHANGED
|
@@ -143,4 +143,19 @@ describe('paths', () => {
|
|
|
143
143
|
assert.strictEqual(paths.find_project_root(), path.resolve(process.cwd()))
|
|
144
144
|
})
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
// NEW-C3 — the server-derived install-dir name must not escape the skills dir.
|
|
148
|
+
describe('install-dir path safety (NEW-C3)', () => {
|
|
149
|
+
const base = path.join(os.tmpdir(), 'hs-skills-base')
|
|
150
|
+
|
|
151
|
+
it('allows a normal (possibly nested) skill dir under base', () => {
|
|
152
|
+
assert.strictEqual(paths.skill_install_dir(base, 'owner/skill'), path.join(base, 'owner/skill'))
|
|
153
|
+
assert.strictEqual(paths.assert_within(base, path.join(base, 'a', 'b')), path.join(base, 'a', 'b'))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('rejects a name that traverses out of base', () => {
|
|
157
|
+
assert.throws(() => paths.skill_install_dir(base, '../../etc/evil'), /escapes the skills directory/)
|
|
158
|
+
assert.throws(() => paths.assert_within(base, '/etc/passwd'), /escapes/)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
146
161
|
})
|
|
@@ -5,6 +5,7 @@ const { Readable } = require('stream')
|
|
|
5
5
|
const tar = require('tar-stream')
|
|
6
6
|
const { error: { catch_errors } } = require('puffy-core')
|
|
7
7
|
const repos_api = require('../api/repos')
|
|
8
|
+
const { MAX_FILE_SIZE, MAX_TOTAL_SIZE, MAX_FILE_COUNT } = require('../config/limits')
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Attempts an archive-based clone. Returns the extracted files or null if no archive is available.
|
|
@@ -25,6 +26,12 @@ const install_from_archive = (owner, name, ref, dest_dir) => catch_errors('Archi
|
|
|
25
26
|
const resp = await fetch(clone_data.url)
|
|
26
27
|
if (!resp.ok) throw new Error(`Archive download failed: ${resp.status}`)
|
|
27
28
|
|
|
29
|
+
// Bound the download before buffering it (SUP-04): a Content-Length past the
|
|
30
|
+
// bundle cap is rejected up front; a missing or lying header is still caught
|
|
31
|
+
// by the streaming decompression caps in extract_tar_gz below.
|
|
32
|
+
const declared = Number(resp.headers.get('content-length') || 0)
|
|
33
|
+
if (declared > MAX_TOTAL_SIZE) throw new Error(`Archive too large: ${declared} bytes exceeds the ${MAX_TOTAL_SIZE}-byte limit`)
|
|
34
|
+
|
|
28
35
|
const archive_buffer = Buffer.from(await resp.arrayBuffer())
|
|
29
36
|
|
|
30
37
|
// Extract tar.gz to dest_dir
|
|
@@ -38,33 +45,47 @@ const install_from_archive = (owner, name, ref, dest_dir) => catch_errors('Archi
|
|
|
38
45
|
*/
|
|
39
46
|
const extract_tar_gz = (buffer, dest_dir) => new Promise((resolve, reject) => {
|
|
40
47
|
const extract = tar.extract()
|
|
48
|
+
const gunzip = zlib.createGunzip()
|
|
49
|
+
let total = 0
|
|
50
|
+
let count = 0
|
|
51
|
+
|
|
52
|
+
// Stop decompression immediately on a cap breach (SUP-04) — destroying gunzip
|
|
53
|
+
// halts inflation so a zip bomb cannot keep expanding onto the consumer's disk
|
|
54
|
+
// or into memory after the breach.
|
|
55
|
+
const abort = (message) => {
|
|
56
|
+
gunzip.destroy()
|
|
57
|
+
extract.destroy()
|
|
58
|
+
reject(new Error(message))
|
|
59
|
+
}
|
|
41
60
|
|
|
42
61
|
extract.on('entry', (header, stream, next) => {
|
|
43
62
|
if (header.type !== 'file') {
|
|
44
63
|
stream.resume()
|
|
45
64
|
return next()
|
|
46
65
|
}
|
|
66
|
+
if (++count > MAX_FILE_COUNT) return abort(`Archive exceeds the ${MAX_FILE_COUNT}-file limit`)
|
|
47
67
|
|
|
48
68
|
// Normalize path: strip leading ./
|
|
49
69
|
const file_path = header.name.replace(/^\.\//, '')
|
|
50
|
-
|
|
51
|
-
// Skip macOS metadata
|
|
52
70
|
const basename = file_path.split('/').pop()
|
|
53
|
-
if (basename.startsWith('._') || basename === '.DS_Store') {
|
|
54
|
-
stream.resume()
|
|
55
|
-
return next()
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Path safety check
|
|
59
71
|
const resolved = path.resolve(dest_dir, file_path)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
// Skip macOS metadata + path-traversal entries — but still METER their bytes
|
|
73
|
+
// against the caps so a bomb hidden in a skipped entry can't slip the cap.
|
|
74
|
+
const is_meta = basename.startsWith('._') || basename === '.DS_Store'
|
|
75
|
+
const is_unsafe = !resolved.startsWith(path.resolve(dest_dir) + path.sep) && resolved !== path.resolve(dest_dir)
|
|
76
|
+
const skip = is_meta || is_unsafe
|
|
64
77
|
|
|
65
78
|
const chunks = []
|
|
66
|
-
|
|
79
|
+
let entry_size = 0
|
|
80
|
+
stream.on('data', chunk => {
|
|
81
|
+
entry_size += chunk.length
|
|
82
|
+
total += chunk.length
|
|
83
|
+
if (entry_size > MAX_FILE_SIZE) return abort(`Archive entry ${header.name} exceeds the ${MAX_FILE_SIZE}-byte per-file limit`)
|
|
84
|
+
if (total > MAX_TOTAL_SIZE) return abort(`Archive decompresses past the ${MAX_TOTAL_SIZE}-byte total limit (possible zip bomb)`)
|
|
85
|
+
if (!skip) chunks.push(chunk)
|
|
86
|
+
})
|
|
67
87
|
stream.on('end', async () => {
|
|
88
|
+
if (skip) return next()
|
|
68
89
|
try {
|
|
69
90
|
const dir = path.dirname(resolved)
|
|
70
91
|
await fs.promises.mkdir(dir, { recursive: true })
|
|
@@ -79,8 +100,8 @@ const extract_tar_gz = (buffer, dest_dir) => new Promise((resolve, reject) => {
|
|
|
79
100
|
|
|
80
101
|
extract.on('finish', resolve)
|
|
81
102
|
extract.on('error', reject)
|
|
103
|
+
gunzip.on('error', reject)
|
|
82
104
|
|
|
83
|
-
const gunzip = zlib.createGunzip()
|
|
84
105
|
Readable.from(buffer).pipe(gunzip).pipe(extract)
|
|
85
106
|
})
|
|
86
107
|
|
|
@@ -5,7 +5,8 @@ const path = require('path')
|
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const zlib = require('zlib')
|
|
7
7
|
const tar = require('tar-stream')
|
|
8
|
-
const { extract_tar_gz } = require('./archive_installer')
|
|
8
|
+
const { extract_tar_gz, install_from_archive } = require('./archive_installer')
|
|
9
|
+
const { MAX_FILE_SIZE, MAX_TOTAL_SIZE, MAX_FILE_COUNT } = require('../config/limits')
|
|
9
10
|
|
|
10
11
|
const create_tar_gz = (files) => new Promise((resolve, reject) => {
|
|
11
12
|
const pack = tar.pack()
|
|
@@ -118,4 +119,54 @@ describe('extract_tar_gz', () => {
|
|
|
118
119
|
const content = await fs.promises.readFile(path.join(tmp_dir, 'file.txt'), 'utf8')
|
|
119
120
|
assert.equal(content, 'dot-slash content')
|
|
120
121
|
})
|
|
122
|
+
|
|
123
|
+
// Zip-bomb / oversize caps (SUP-04, spec 260603-02 §4.T1-2). { todo: true }
|
|
124
|
+
// until the streaming caps land — un-todo when implemented.
|
|
125
|
+
it('rejects a zip bomb that decompresses past the cap (mid-stream)', async () => {
|
|
126
|
+
const buffer = await create_tar_gz([{ name: 'bomb', content: Buffer.alloc(8 * 1024 * 1024, 0) }])
|
|
127
|
+
assert.ok(buffer.length < 64 * 1024, 'sanity: tiny compressed')
|
|
128
|
+
await assert.rejects(extract_tar_gz(buffer, tmp_dir))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('rejects a single entry larger than MAX_FILE_SIZE', async () => {
|
|
132
|
+
const buffer = await create_tar_gz([{ name: 'big', content: Buffer.alloc(MAX_FILE_SIZE + 1024, 7) }])
|
|
133
|
+
await assert.rejects(extract_tar_gz(buffer, tmp_dir))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('rejects when the running total decompressed exceeds MAX_TOTAL_SIZE', async () => {
|
|
137
|
+
const each = Math.ceil(MAX_TOTAL_SIZE * 0.6)
|
|
138
|
+
const buffer = await create_tar_gz([
|
|
139
|
+
{ name: 'a', content: Buffer.alloc(each, 1) },
|
|
140
|
+
{ name: 'b', content: Buffer.alloc(each, 2) }
|
|
141
|
+
])
|
|
142
|
+
await assert.rejects(extract_tar_gz(buffer, tmp_dir))
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('rejects when the entry count exceeds MAX_FILE_COUNT', async () => {
|
|
146
|
+
const limit = MAX_FILE_COUNT || 1000
|
|
147
|
+
const files = Array.from({ length: limit + 1 }, (_, i) => ({ name: `f${i}.txt`, content: 'x' }))
|
|
148
|
+
const buffer = await create_tar_gz(files)
|
|
149
|
+
await assert.rejects(extract_tar_gz(buffer, tmp_dir))
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('install_from_archive — Content-Length guard before buffering (SUP-04)', () => {
|
|
154
|
+
it('rejects an oversized download via Content-Length, before calling arrayBuffer()', async () => {
|
|
155
|
+
const repos_api = require('../api/repos')
|
|
156
|
+
const orig_clone = repos_api.clone
|
|
157
|
+
const orig_fetch = global.fetch
|
|
158
|
+
repos_api.clone = async () => [null, { format: 'archive', url: 'https://example.com/archive.tar.gz', ref: 'refs/tags/v1', commit: 'abc' }]
|
|
159
|
+
global.fetch = async () => ({
|
|
160
|
+
ok: true,
|
|
161
|
+
headers: { get: (k) => (String(k).toLowerCase() === 'content-length' ? String(MAX_TOTAL_SIZE * 4) : null) },
|
|
162
|
+
arrayBuffer: async () => { throw new Error('must reject before buffering the body') }
|
|
163
|
+
})
|
|
164
|
+
try {
|
|
165
|
+
const [err] = await install_from_archive('owner', 'name', 'refs/tags/v1', '/tmp/nope')
|
|
166
|
+
assert.ok(err, 'oversized archive rejected before buffering')
|
|
167
|
+
} finally {
|
|
168
|
+
repos_api.clone = orig_clone
|
|
169
|
+
global.fetch = orig_fetch
|
|
170
|
+
}
|
|
171
|
+
})
|
|
121
172
|
})
|
package/src/ui/output.js
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
const { red, green, yellow, cyan, bold, dim, gray, code, enabled } = require('./colors')
|
|
2
2
|
const { is_json_mode } = require('../state')
|
|
3
3
|
|
|
4
|
+
// NEW-C2 (spec 260603-02 §4 Tier 3). Server-supplied strings (skill names, API
|
|
5
|
+
// error messages) are printed straight to a terminal; a crafted value carrying
|
|
6
|
+
// ESC / control bytes could rewrite the screen, spoof output, or run a terminal
|
|
7
|
+
// escape attack. Strip C0/C1 control bytes (keeping only \t and \n) at the print
|
|
8
|
+
// boundary. The CLI's own ANSI coloring is applied AFTER this scrub, so legitimate
|
|
9
|
+
// colors are unaffected. (NO_COLOR is honored separately in ./colors.)
|
|
10
|
+
const scrub_terminal = (str) => String(str == null ? '' : str).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
|
|
11
|
+
|
|
4
12
|
const print_success = (msg) => {
|
|
5
13
|
if (is_json_mode()) return
|
|
6
|
-
console.log(green(`✓ ${msg}`))
|
|
14
|
+
console.log(green(`✓ ${scrub_terminal(msg)}`))
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
const print_error = (msg) => {
|
|
10
|
-
console.error(red(`✗ ${msg}`))
|
|
18
|
+
console.error(red(`✗ ${scrub_terminal(msg)}`))
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
const print_warn = (msg) => {
|
|
14
|
-
console.error(yellow(`⚠ ${msg}`))
|
|
22
|
+
console.error(yellow(`⚠ ${scrub_terminal(msg)}`))
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
const print_info = (msg) => {
|
|
18
26
|
if (is_json_mode()) return
|
|
19
|
-
console.log(cyan(`ℹ ${msg}`))
|
|
27
|
+
console.log(cyan(`ℹ ${scrub_terminal(msg)}`))
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
// Subtle post-command suggestion line
|
|
23
31
|
const print_hint = (msg) => {
|
|
24
32
|
if (is_json_mode()) return
|
|
25
|
-
console.log(dim(` → ${msg}`))
|
|
33
|
+
console.log(dim(` → ${scrub_terminal(msg)}`))
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
// Style help text: bold section headers, dim example lines
|
|
@@ -96,7 +104,7 @@ const print_table = (headers, rows) => {
|
|
|
96
104
|
console.log(separator)
|
|
97
105
|
rows.forEach(row => {
|
|
98
106
|
const line = row.map((cell, i) => {
|
|
99
|
-
const str = String(cell || '')
|
|
107
|
+
const str = scrub_terminal(String(cell || '')) // NEW-C2 — server data into the terminal
|
|
100
108
|
const t = truncate(str, col_widths[i])
|
|
101
109
|
return ansi_pad(t, col_widths[i])
|
|
102
110
|
}).join(' ')
|
|
@@ -184,7 +192,7 @@ const print_json = (input) => {
|
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
const print_label = (label, value) => {
|
|
187
|
-
console.log(`${gray(label + ':')} ${value}`)
|
|
195
|
+
console.log(`${gray(label + ':')} ${scrub_terminal(value)}`)
|
|
188
196
|
}
|
|
189
197
|
|
|
190
|
-
module.exports = { print_success, print_error, print_warn, print_info, print_hint, print_help, print_table, print_json, print_label, code, format_help, visible_len, ansi_pad, truncate, summarize_warnings }
|
|
198
|
+
module.exports = { print_success, print_error, print_warn, print_info, print_hint, print_help, print_table, print_json, print_label, code, format_help, visible_len, ansi_pad, truncate, summarize_warnings, scrub_terminal }
|
package/src/ui/output.test.js
CHANGED
|
@@ -2,7 +2,29 @@
|
|
|
2
2
|
const { describe, it, before, after, beforeEach, afterEach } = require('node:test')
|
|
3
3
|
const assert = require('node:assert/strict')
|
|
4
4
|
|
|
5
|
-
const { visible_len, ansi_pad, truncate, format_help, print_table, summarize_warnings } = require('./output')
|
|
5
|
+
const { visible_len, ansi_pad, truncate, format_help, print_table, summarize_warnings, scrub_terminal } = require('./output')
|
|
6
|
+
|
|
7
|
+
// ─── scrub_terminal (NEW-C2) ──────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
describe('scrub_terminal — strips terminal control bytes from server strings (NEW-C2)', () => {
|
|
10
|
+
it('removes ESC and other C0/C1 control bytes (neutralizing escape sequences)', () => {
|
|
11
|
+
// The ESC byte (0x1b) is stripped, so the leftover "[31m" is inert literal
|
|
12
|
+
// text the terminal will not interpret — the attack is neutralized.
|
|
13
|
+
assert.strictEqual(scrub_terminal('a\x1b[31mb\x07c'), 'a[31mbc') // ESC + BEL bytes gone
|
|
14
|
+
assert.ok(!scrub_terminal('a\x1b[31mb').includes('\x1b'), 'no ESC byte survives')
|
|
15
|
+
assert.strictEqual(scrub_terminal('x\x9by'), 'xy') // C1
|
|
16
|
+
assert.strictEqual(scrub_terminal('a\rb'), 'ab') // CR (line-overwrite) stripped
|
|
17
|
+
assert.strictEqual(scrub_terminal('a\x7fb'), 'ab') // DEL
|
|
18
|
+
})
|
|
19
|
+
it('keeps tabs, newlines, and normal text intact', () => {
|
|
20
|
+
assert.strictEqual(scrub_terminal('a\tb\nc'), 'a\tb\nc')
|
|
21
|
+
assert.strictEqual(scrub_terminal('normal skill name'), 'normal skill name')
|
|
22
|
+
})
|
|
23
|
+
it('coerces nullish to empty string', () => {
|
|
24
|
+
assert.strictEqual(scrub_terminal(null), '')
|
|
25
|
+
assert.strictEqual(scrub_terminal(undefined), '')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
6
28
|
|
|
7
29
|
// ─── visible_len ─────────────────────────────────────────────────────────────
|
|
8
30
|
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Rules:
|
|
5
5
|
// 1. Strip OpenAI API keys (sk-...)
|
|
6
|
-
// 2. Strip GitHub tokens (ghp_
|
|
6
|
+
// 2. Strip GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_...)
|
|
7
7
|
// 3. Strip Cognito JWT-shaped tokens (eyJ...)
|
|
8
|
-
// 4. Strip
|
|
8
|
+
// 4. Strip AWS access key IDs (AKIA.../ASIA...)
|
|
9
|
+
// 5. Strip postgres connection strings (postgres://... — carry credentials)
|
|
10
|
+
// 6. Strip values for env-var keys ending in _TOKEN, _KEY, _SECRET, _PASSWORD
|
|
9
11
|
//
|
|
10
12
|
// The replacement is the literal string `<redacted>` so callers can see that
|
|
11
13
|
// a value WAS present and got stripped — vs missing entirely.
|
|
@@ -13,9 +15,10 @@
|
|
|
13
15
|
const REDACTED = '<redacted>'
|
|
14
16
|
|
|
15
17
|
const PATTERNS = [
|
|
16
|
-
/sk-[A-Za-z0-9_-]{20,}/g,
|
|
17
|
-
/
|
|
18
|
-
|
|
18
|
+
/sk-[A-Za-z0-9_-]{20,}/g, // OpenAI keys
|
|
19
|
+
/gh[pousr]_[A-Za-z0-9]{30,}/g, // GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_)
|
|
20
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, // AWS access key IDs (long-term + temporary)
|
|
21
|
+
/postgres(?:ql)?:\/\/[^\s'"]+/gi, // postgres connection strings (may carry user:pass@host)
|
|
19
22
|
/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g // JWT shape
|
|
20
23
|
]
|
|
21
24
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
|
|
4
|
+
// DATA-04 (spec 260603-02) — the scrubber must catch the token/key/connection-
|
|
5
|
+
// string shapes that were missing, WITHOUT over-redacting ordinary prose. Keep
|
|
6
|
+
// in lockstep with web/src/lib/scrub-secrets.js.
|
|
7
|
+
const { scrub, scrub_string, REDACTED } = require('./scrub_secrets')
|
|
8
|
+
|
|
9
|
+
describe('scrub_secrets — DATA-04 expanded patterns', () => {
|
|
10
|
+
it('redacts all GitHub token prefixes (ghp/gho/ghu/ghs/ghr)', () => {
|
|
11
|
+
for (const p of ['ghp', 'gho', 'ghu', 'ghs', 'ghr']) {
|
|
12
|
+
assert.equal(scrub_string(`x ${p}_${'a'.repeat(36)} y`), `x ${REDACTED} y`, `${p}_ token redacted`)
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
it('redacts AWS access key IDs (AKIA + ASIA)', () => {
|
|
16
|
+
assert.equal(scrub_string('id=AKIAIOSFODNN7EXAMPLE.'), `id=${REDACTED}.`)
|
|
17
|
+
assert.equal(scrub_string('ASIAY34FZKBOKMUTVV7A'), REDACTED)
|
|
18
|
+
})
|
|
19
|
+
it('redacts postgres / postgresql connection strings (which carry credentials)', () => {
|
|
20
|
+
assert.equal(scrub_string('postgres://u:p@host.neon.tech/db?sslmode=require'), REDACTED)
|
|
21
|
+
assert.equal(scrub_string('postgresql://u:p@h/d'), REDACTED)
|
|
22
|
+
})
|
|
23
|
+
it('still redacts the original shapes (OpenAI, JWT)', () => {
|
|
24
|
+
assert.equal(scrub_string(`sk-${'a'.repeat(24)}`), REDACTED)
|
|
25
|
+
assert.match(scrub_string('eyJabc.eyJdef.sig'), new RegExp(REDACTED))
|
|
26
|
+
})
|
|
27
|
+
it('does NOT over-redact ordinary prose / near-misses', () => {
|
|
28
|
+
assert.equal(scrub_string('postgresql is a database engine'), 'postgresql is a database engine')
|
|
29
|
+
assert.equal(scrub_string('AKIANOTAKEY'), 'AKIANOTAKEY') // not 16 trailing chars
|
|
30
|
+
assert.equal(scrub_string('a normal sentence, email a@b.com'), 'a normal sentence, email a@b.com')
|
|
31
|
+
})
|
|
32
|
+
it('recursively redacts inside objects + sensitive keys', () => {
|
|
33
|
+
const out = scrub({ note: `key ghs_${'z'.repeat(36)}`, DATABASE_URL: 'postgres://u:p@h/d', nested: { token: 'eyJa.eyJb.c' } })
|
|
34
|
+
assert.equal(out.note, `key ${REDACTED}`)
|
|
35
|
+
assert.equal(out.DATABASE_URL, REDACTED)
|
|
36
|
+
assert.match(out.nested.token, new RegExp(REDACTED))
|
|
37
|
+
})
|
|
38
|
+
})
|