@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.11
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/out/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/dashboard/provider.js +13 -2
- package/out/analytics/dashboard/webview/panel.js +3 -2
- package/out/analytics/identity.js +0 -0
- package/out/analytics/service.js +29 -25
- package/out/analytics/sessionEvents.js +100 -24
- package/out/auth/authBaseUrl.js +59 -0
- package/out/auth/bootstrapApply.js +9 -10
- package/out/auth/brokerClient.js +48 -14
- package/out/auth/provider.js +8 -21
- package/out/auth/sessionStore.js +3 -40
- package/out/auth/signInFlow.js +2 -1
- package/out/auth-broker/config.js +50 -5
- package/out/auth-broker/server.js +126 -26
- package/out/auth-broker/store.js +25 -24
- package/out/authority/warrant.js +206 -0
- package/out/branding.js +27 -1
- package/out/cli/commands/conformance.js +249 -0
- package/out/cli/commands/data-plane.js +151 -0
- package/out/cli/commands/deploy.js +1 -0
- package/out/cli/commands/gateway.js +4 -0
- package/out/cli/commands/pack.js +182 -3
- package/out/cli/commands/sandbox.js +351 -0
- package/out/cli/commands/serve.js +1145 -130
- package/out/cli/commands/store.js +12 -4
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +18 -2
- package/out/cli/data-plane/registry.js +9 -0
- package/out/cli/data-plane/remote.js +106 -0
- package/out/cli/data-plane/sidecar.js +225 -0
- package/out/cli/deploy/layers/identity.js +88 -4
- package/out/cli/deploy-init.js +89 -24
- package/out/cli/deps.js +106 -2
- package/out/cli/idp-init.js +35 -0
- package/out/cli/index.js +171 -17
- package/out/cli/pack-install-ref.js +15 -0
- package/out/cli/pack-loader.js +8 -1
- package/out/cli/pack-runtime.js +61 -0
- package/out/cli/runtime.js +35 -1
- package/out/cli/serve-hardening.js +260 -14
- package/out/cli/utils.js +12 -1
- package/out/engines/base.js +32 -3
- package/out/engines/claude.js +33 -15
- package/out/engines/codex.js +111 -27
- package/out/engines/custom.js +15 -1
- package/out/evidence/sessionShip.js +643 -0
- package/out/extension.js +11 -2
- package/out/gateway/bridge.js +40 -2
- package/out/gateway/client.js +34 -1
- package/out/gateway/installSource.js +68 -0
- package/out/gateway/lifecycle.js +185 -51
- package/out/gateway/outboundPaths.js +4 -1
- package/out/gateway/secretsPreflight.js +90 -0
- package/out/gateway/service.js +7 -2
- package/out/headless/index.js +25 -2
- package/out/live-e2e/mcp-demo/server.mjs +32 -0
- package/out/mcp.js +30 -0
- package/out/pack/caps.js +271 -0
- package/out/pack/governor.js +326 -0
- package/out/pack/lease-schema.js +273 -0
- package/out/pack/lease.js +682 -0
- package/out/pack/reconcile.js +217 -0
- package/out/pack/signature.js +839 -0
- package/out/remote/handleRemoteChat.js +44 -3
- package/out/remote/remoteTrust.js +277 -0
- package/out/remote/serveClient.js +52 -5
- package/out/runner/host.js +141 -0
- package/out/runner/protocol.js +128 -0
- package/out/runner/remoteEngine.js +166 -0
- package/out/runtime/interfaces/edit-tracker.js +2 -0
- package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
- package/out/sandbox/containerDriver.js +572 -0
- package/out/sandbox/processDriver.js +318 -0
- package/out/sandbox/sandboxRunner.js +303 -0
- package/out/sandbox/support.js +301 -0
- package/out/sandbox/types.js +55 -0
- package/out/scripts/agentide-litellm.sh +67 -5
- package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
- package/out/scripts/analytics/setup-analytics.ps1 +45 -14
- package/out/scripts/analytics/setup-analytics.sh +106 -15
- package/out/scripts/analytics/start-analytics.sh +86 -14
- package/out/scripts/gateway/setup-gateway.sh +72 -32
- package/out/shared/models.js +11 -110
- package/out/spotlight/install.js +7 -1
- package/out/store/index.js +41 -10
- package/out/sync.js +3 -1
- package/out/test/analytics.test.js +162 -23
- package/out/test/auth-baseurl.test.js +132 -0
- package/out/test/auth-bootstrapApply.test.js +27 -6
- package/out/test/auth-broker-hardening.test.js +227 -0
- package/out/test/auth-broker.test.js +59 -9
- package/out/test/branding.test.js +13 -0
- package/out/test/caps-governor.test.js +460 -0
- package/out/test/codex-new-file-survival.test.js +119 -0
- package/out/test/configuration-scopes.test.js +131 -0
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +380 -0
- package/out/test/deploy-init.test.js +47 -1
- package/out/test/gateway-client.test.js +28 -0
- package/out/test/gateway-install-source.test.js +111 -0
- package/out/test/gateway-lifecycle.test.js +1 -1
- package/out/test/gateway-outbound.test.js +7 -0
- package/out/test/gateway-secrets-preflight.test.js +127 -0
- package/out/test/gateway-session-routing.test.js +18 -0
- package/out/test/governor-hardening.test.js +222 -0
- package/out/test/headless-sync.test.js +22 -0
- package/out/test/lease-signature.test.js +357 -0
- package/out/test/lease-trust-root.test.js +273 -0
- package/out/test/lease-validation.test.js +224 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/mcp.test.js +27 -1
- package/out/test/pack-admission.test.js +216 -0
- package/out/test/pack-digest-parity.test.js +140 -0
- package/out/test/pack-reconcile.test.js +183 -0
- package/out/test/pack-signature-artifact.test.js +103 -0
- package/out/test/pack-signature.test.js +176 -0
- package/out/test/pack-trust-root.test.js +202 -0
- package/out/test/remote-trust.test.js +242 -0
- package/out/test/runner-protocol.test.js +249 -0
- package/out/test/sandbox-driver.test.js +553 -0
- package/out/test/sandbox-hardening.test.js +406 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +227 -15
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +618 -0
- package/out/test/spotlightPublish.test.js +28 -0
- package/out/test/store-install-safe.test.js +69 -0
- package/out/test/tls-bypass.test.js +90 -0
- package/out/test/warrant.test.js +138 -0
- package/out/utils.js +46 -9
- package/package.json +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Pack admission: fail closed by default, re-verified per turn, and say WHY.
|
|
4
|
+
*
|
|
5
|
+
* Three gaps, one suite:
|
|
6
|
+
* - the default was warn-and-run, so "verify before run, fail closed" was not true of the
|
|
7
|
+
* shipped shape;
|
|
8
|
+
* - the verdict was computed once at boot and cached for the life of the process, while the
|
|
9
|
+
* default deployment runs the engine in-process against a `fileAccess: full` pack directory;
|
|
10
|
+
* - each distinct failure needs its own message, because each has a different remedy.
|
|
11
|
+
*--------------------------------------------------------------------------------------------*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
require("mocha");
|
|
47
|
+
const assert = __importStar(require("assert"));
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const os = __importStar(require("os"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const warrant_1 = require("../authority/warrant");
|
|
52
|
+
const signature_1 = require("../pack/signature");
|
|
53
|
+
const key = (0, warrant_1.generateWarrantKeypair)('k1');
|
|
54
|
+
const keys = { k1: key.publicKeyPem };
|
|
55
|
+
function makePack(files = { 'agent.yaml': 'name: demo\n' }) {
|
|
56
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-admission-'));
|
|
57
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
58
|
+
const abs = path.join(dir, rel);
|
|
59
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
60
|
+
fs.writeFileSync(abs, body);
|
|
61
|
+
}
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
const sign = (dir) => (0, signature_1.signPack)({ packDir: dir, pack: 'demo', packVersion: '1.0.0', privateKeyPem: key.privateKeyPem, kid: 'k1' });
|
|
65
|
+
suite('signing is required by DEFAULT', () => {
|
|
66
|
+
test('nothing configured ⇒ a signature is required', () => {
|
|
67
|
+
assert.strictEqual((0, signature_1.resolveRequireSignedPack)({}), true);
|
|
68
|
+
});
|
|
69
|
+
test('the escape hatch relaxes only this instance', () => {
|
|
70
|
+
assert.strictEqual((0, signature_1.resolveRequireSignedPack)({ localAllowUnsigned: true }), false);
|
|
71
|
+
});
|
|
72
|
+
test('a lease demand OVERRIDES the escape hatch — tighten-only survives the flip', () => {
|
|
73
|
+
assert.strictEqual((0, signature_1.resolveRequireSignedPack)({ lease: true, localAllowUnsigned: true }), true, 'an instance must never be able to waive what the control plane demanded');
|
|
74
|
+
});
|
|
75
|
+
test('--require-signed-pack still means something, and still cannot be waived', () => {
|
|
76
|
+
assert.strictEqual((0, signature_1.resolveRequireSignedPack)({ localRequire: true, localAllowUnsigned: true }), true);
|
|
77
|
+
});
|
|
78
|
+
test('the underlying tighten-only OR is unchanged', () => {
|
|
79
|
+
assert.strictEqual((0, signature_1.requireSignedPack)(undefined, undefined), false);
|
|
80
|
+
assert.strictEqual((0, signature_1.requireSignedPack)(true, undefined), true);
|
|
81
|
+
assert.strictEqual((0, signature_1.requireSignedPack)(undefined, true), true);
|
|
82
|
+
assert.strictEqual((0, signature_1.requireSignedPack)(true, true), true);
|
|
83
|
+
});
|
|
84
|
+
test('the escape hatch env var is explicit, not merely present', () => {
|
|
85
|
+
assert.strictEqual((0, signature_1.allowUnsignedPackFromEnv)({ [signature_1.ALLOW_UNSIGNED_PACK_ENV]: '1' }), true);
|
|
86
|
+
assert.strictEqual((0, signature_1.allowUnsignedPackFromEnv)({ [signature_1.ALLOW_UNSIGNED_PACK_ENV]: 'true' }), true);
|
|
87
|
+
assert.strictEqual((0, signature_1.allowUnsignedPackFromEnv)({ [signature_1.ALLOW_UNSIGNED_PACK_ENV]: '0' }), false);
|
|
88
|
+
assert.strictEqual((0, signature_1.allowUnsignedPackFromEnv)({}), false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
suite('each refusal names its own remedy', () => {
|
|
92
|
+
const cases = [
|
|
93
|
+
['unsigned', /pack sign/],
|
|
94
|
+
['no-trust-root', /AGENTHIPPO_PACK_JWKS_URL/],
|
|
95
|
+
['trust-root-unavailable', /could not be loaded/],
|
|
96
|
+
['unknown-key', /does not trust/],
|
|
97
|
+
['digest-mismatch', /changed after signing/],
|
|
98
|
+
['identity-mismatch', /different pack or version/],
|
|
99
|
+
];
|
|
100
|
+
for (const [kind, expected] of cases) {
|
|
101
|
+
test(`${kind} produces its own actionable message`, () => {
|
|
102
|
+
const msg = (0, signature_1.describePackRefusal)({ kind, detail: 'detail here', pack: 'demo', packVersion: '1.0.0', packDir: '/p' });
|
|
103
|
+
assert.match(msg, /refusing to run demo@1\.0\.0/);
|
|
104
|
+
assert.match(msg, expected);
|
|
105
|
+
assert.match(msg, /AGENTHIPPO_ALLOW_UNSIGNED_PACK=1/, 'every refusal must state how to get moving again');
|
|
106
|
+
assert.match(msg, /cannot be waived locally/, 'and that the lease still wins');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
test('"no trust root" and "unsigned" are different messages with different fixes', () => {
|
|
110
|
+
const unsigned = (0, signature_1.describePackRefusal)({ kind: 'unsigned', detail: 'x', pack: 'demo' });
|
|
111
|
+
const noRoot = (0, signature_1.describePackRefusal)({ kind: 'no-trust-root', detail: 'x', pack: 'demo' });
|
|
112
|
+
assert.notStrictEqual(unsigned, noRoot);
|
|
113
|
+
assert.ok(!/NOT a problem with the pack/.test(unsigned));
|
|
114
|
+
assert.match(noRoot, /NOT a problem with the pack/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
suite('the verifier refuses each distinct failure with its kind', () => {
|
|
118
|
+
test('unsigned', () => {
|
|
119
|
+
const v = (0, signature_1.createPackVerifier)();
|
|
120
|
+
const res = v.verify({ packDir: makePack(), pack: 'demo', packVersion: '1.0.0', keys });
|
|
121
|
+
assert.strictEqual(res.ok, false);
|
|
122
|
+
assert.strictEqual(res.kind, 'unsigned');
|
|
123
|
+
});
|
|
124
|
+
test('no trust root — distinguishable from unsigned', () => {
|
|
125
|
+
const dir = makePack();
|
|
126
|
+
sign(dir);
|
|
127
|
+
const res = (0, signature_1.createPackVerifier)().verify({ packDir: dir, pack: 'demo', packVersion: '1.0.0', keys: {} });
|
|
128
|
+
assert.strictEqual(res.ok, false);
|
|
129
|
+
assert.strictEqual(res.kind, 'no-trust-root');
|
|
130
|
+
});
|
|
131
|
+
test('unknown signing key', () => {
|
|
132
|
+
const dir = makePack();
|
|
133
|
+
sign(dir);
|
|
134
|
+
const stranger = (0, warrant_1.generateWarrantKeypair)('other');
|
|
135
|
+
const res = (0, signature_1.createPackVerifier)().verify({ packDir: dir, pack: 'demo', packVersion: '1.0.0', keys: { other: stranger.publicKeyPem } });
|
|
136
|
+
assert.strictEqual(res.ok, false);
|
|
137
|
+
assert.strictEqual(res.kind, 'unknown-key');
|
|
138
|
+
});
|
|
139
|
+
test('digest mismatch', () => {
|
|
140
|
+
const dir = makePack();
|
|
141
|
+
sign(dir);
|
|
142
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), 'name: demo\nevil: true\n');
|
|
143
|
+
const res = (0, signature_1.createPackVerifier)().verify({ packDir: dir, pack: 'demo', packVersion: '1.0.0', keys });
|
|
144
|
+
assert.strictEqual(res.ok, false);
|
|
145
|
+
assert.strictEqual(res.kind, 'digest-mismatch');
|
|
146
|
+
});
|
|
147
|
+
test('identity mismatch — a valid signature for a different pack', () => {
|
|
148
|
+
const dir = makePack();
|
|
149
|
+
sign(dir);
|
|
150
|
+
const res = (0, signature_1.createPackVerifier)().verify({ packDir: dir, pack: 'payments', packVersion: '1.0.0', keys });
|
|
151
|
+
assert.strictEqual(res.ok, false);
|
|
152
|
+
assert.strictEqual(res.kind, 'identity-mismatch');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
suite('re-verification on the turn path', () => {
|
|
156
|
+
/**
|
|
157
|
+
* The exact scenario: no --sandbox, so the engine runs in-process with `fileAccess: full`. The
|
|
158
|
+
* agent edits its own pack directory after boot. Under the old boot-once cache every subsequent
|
|
159
|
+
* turn passed on a stale `true`.
|
|
160
|
+
*/
|
|
161
|
+
test('a pack edited AFTER the first verification is caught on the next turn', () => {
|
|
162
|
+
const dir = makePack();
|
|
163
|
+
sign(dir);
|
|
164
|
+
const v = (0, signature_1.createPackVerifier)({ probeIntervalMs: 0 });
|
|
165
|
+
const input = { packDir: dir, pack: 'demo', packVersion: '1.0.0', keys };
|
|
166
|
+
assert.ok(v.verify(input, 1_000).ok, 'clean at boot');
|
|
167
|
+
fs.writeFileSync(path.join(dir, '.claude', 'settings.json').replace('.claude/', ''), 'x');
|
|
168
|
+
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
|
|
169
|
+
fs.writeFileSync(path.join(dir, '.claude', 'settings.json'), '{"hooks":{"PreToolUse":[{"command":"sh"}]}}');
|
|
170
|
+
const after = v.verify(input, 2_000);
|
|
171
|
+
assert.strictEqual(after.ok, false, 'the injected hook must be caught on a later turn');
|
|
172
|
+
assert.strictEqual(after.kind, 'digest-mismatch');
|
|
173
|
+
});
|
|
174
|
+
test('an unchanged pack is NOT re-hashed on every turn — the probe is what makes this affordable', () => {
|
|
175
|
+
const dir = makePack();
|
|
176
|
+
sign(dir);
|
|
177
|
+
const v = (0, signature_1.createPackVerifier)({ probeIntervalMs: 0, fullReverifyIntervalMs: 1_000_000 });
|
|
178
|
+
const input = { packDir: dir, pack: 'demo', packVersion: '1.0.0', keys };
|
|
179
|
+
v.verify(input, 1_000);
|
|
180
|
+
const afterFirst = v.fullVerifications();
|
|
181
|
+
for (let i = 0; i < 50; i++) {
|
|
182
|
+
assert.ok(v.verify(input, 1_000 + i).ok);
|
|
183
|
+
}
|
|
184
|
+
assert.strictEqual(v.fullVerifications(), afterFirst, '50 turns, one digest');
|
|
185
|
+
});
|
|
186
|
+
test('a full re-verify happens on an interval regardless of how clean the probe looks', () => {
|
|
187
|
+
const dir = makePack();
|
|
188
|
+
sign(dir);
|
|
189
|
+
const v = (0, signature_1.createPackVerifier)({ probeIntervalMs: 0, fullReverifyIntervalMs: 5_000 });
|
|
190
|
+
const input = { packDir: dir, pack: 'demo', packVersion: '1.0.0', keys };
|
|
191
|
+
v.verify(input, 0);
|
|
192
|
+
const before = v.fullVerifications();
|
|
193
|
+
v.verify(input, 1_000);
|
|
194
|
+
assert.strictEqual(v.fullVerifications(), before, 'within the interval, the probe decides');
|
|
195
|
+
v.verify(input, 6_000);
|
|
196
|
+
assert.strictEqual(v.fullVerifications(), before + 1, 'past it, we hash regardless — bounds the mtime-restoring attacker');
|
|
197
|
+
});
|
|
198
|
+
test('changing the keyring or the version invalidates the cached verdict outright', () => {
|
|
199
|
+
const dir = makePack();
|
|
200
|
+
sign(dir);
|
|
201
|
+
const v = (0, signature_1.createPackVerifier)({ fullReverifyIntervalMs: 1_000_000 });
|
|
202
|
+
assert.ok(v.verify({ packDir: dir, pack: 'demo', packVersion: '1.0.0', keys }, 0).ok);
|
|
203
|
+
const res = v.verify({ packDir: dir, pack: 'demo', packVersion: '2.0.0', keys }, 1);
|
|
204
|
+
assert.strictEqual(res.ok, false, 'a version change must not reuse the previous verdict');
|
|
205
|
+
});
|
|
206
|
+
test('a probe within the probe interval is not even taken', () => {
|
|
207
|
+
const dir = makePack();
|
|
208
|
+
sign(dir);
|
|
209
|
+
const v = (0, signature_1.createPackVerifier)({ probeIntervalMs: 10_000, fullReverifyIntervalMs: 1_000_000 });
|
|
210
|
+
const input = { packDir: dir, pack: 'demo', packVersion: '1.0.0', keys };
|
|
211
|
+
v.verify(input, 0);
|
|
212
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), 'tampered');
|
|
213
|
+
assert.ok(v.verify(input, 5_000).ok, 'inside the probe interval the cached verdict stands (documented)');
|
|
214
|
+
assert.strictEqual(v.verify(input, 11_000).ok, false, 'and the very next probe catches it');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* The two digest paths must agree, or a locally-signed pack can never verify through the store.
|
|
4
|
+
*
|
|
5
|
+
* `computePackDigest` (directory walk) excluded SEVEN names at every level;
|
|
6
|
+
* `computePackDigestFromFiles` (fetched artifact) excluded ONE. Any pack containing `.claude/`,
|
|
7
|
+
* `.codex/` or `.agent-hippo/` therefore produced two different digests depending on which path
|
|
8
|
+
* looked at it — and the walk's exclusions were exactly where executable content lives.
|
|
9
|
+
*--------------------------------------------------------------------------------------------*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
require("mocha");
|
|
45
|
+
const assert = __importStar(require("assert"));
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const os = __importStar(require("os"));
|
|
48
|
+
const path = __importStar(require("path"));
|
|
49
|
+
const reconcile_1 = require("../pack/reconcile");
|
|
50
|
+
const signature_1 = require("../pack/signature");
|
|
51
|
+
function makeTree() {
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'digest-parity-'));
|
|
53
|
+
const write = (rel, body) => {
|
|
54
|
+
const abs = path.join(dir, rel);
|
|
55
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
56
|
+
fs.writeFileSync(abs, body);
|
|
57
|
+
};
|
|
58
|
+
write('agent.yaml', 'name: demo\n');
|
|
59
|
+
write('AGENTS.md', '# demo\n');
|
|
60
|
+
write('skills/a/SKILL.md', 'do a thing');
|
|
61
|
+
// Executable content that used to sit OUTSIDE the signature at every level.
|
|
62
|
+
write('.claude/settings.json', '{"hooks":{}}');
|
|
63
|
+
write('.codex/config.toml', 'model = "x"');
|
|
64
|
+
write('.agent-hippo/agents/x/AGENTS.md', 'nested');
|
|
65
|
+
write('nested/.claude/settings.json', '{"hooks":{"PreToolUse":[]}}');
|
|
66
|
+
// OS litter, now hashed like anything else.
|
|
67
|
+
write('.DS_Store', 'finder junk');
|
|
68
|
+
// Genuinely not pack content, excluded at every depth.
|
|
69
|
+
write('.git/HEAD', 'ref: refs/heads/main');
|
|
70
|
+
write('node_modules/dep/index.js', 'module.exports = 1');
|
|
71
|
+
write('skills/node_modules/nested-dep/index.js', 'module.exports = 2');
|
|
72
|
+
// The signature cannot cover itself.
|
|
73
|
+
write(signature_1.PACK_SIGNATURE_FILE, 'a.b.c');
|
|
74
|
+
fs.symlinkSync('agent.yaml', path.join(dir, 'link-to-agent'));
|
|
75
|
+
return dir;
|
|
76
|
+
}
|
|
77
|
+
suite('digest parity between the tree walk and the file list', () => {
|
|
78
|
+
test('identical trees hash identically through both paths', () => {
|
|
79
|
+
const dir = makeTree();
|
|
80
|
+
assert.strictEqual((0, signature_1.computePackDigestFromFiles)((0, reconcile_1.readDirAsFiles)(dir)), (0, signature_1.computePackDigest)(dir), 'a pack signed on disk must verify from its own file list');
|
|
81
|
+
});
|
|
82
|
+
test('a change under .claude moves BOTH digests, not just one', () => {
|
|
83
|
+
const dir = makeTree();
|
|
84
|
+
const beforeWalk = (0, signature_1.computePackDigest)(dir);
|
|
85
|
+
const beforeFiles = (0, signature_1.computePackDigestFromFiles)((0, reconcile_1.readDirAsFiles)(dir));
|
|
86
|
+
fs.writeFileSync(path.join(dir, '.claude', 'settings.json'), '{"hooks":{"PreToolUse":[{"command":"sh -c evil"}]}}');
|
|
87
|
+
assert.notStrictEqual((0, signature_1.computePackDigest)(dir), beforeWalk);
|
|
88
|
+
assert.notStrictEqual((0, signature_1.computePackDigestFromFiles)((0, reconcile_1.readDirAsFiles)(dir)), beforeFiles);
|
|
89
|
+
assert.strictEqual((0, signature_1.computePackDigestFromFiles)((0, reconcile_1.readDirAsFiles)(dir)), (0, signature_1.computePackDigest)(dir));
|
|
90
|
+
});
|
|
91
|
+
test('a change under node_modules or .git moves neither', () => {
|
|
92
|
+
const dir = makeTree();
|
|
93
|
+
const walk = (0, signature_1.computePackDigest)(dir);
|
|
94
|
+
const files = (0, signature_1.computePackDigestFromFiles)((0, reconcile_1.readDirAsFiles)(dir));
|
|
95
|
+
fs.writeFileSync(path.join(dir, 'node_modules', 'dep', 'index.js'), 'module.exports = 99');
|
|
96
|
+
fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/other');
|
|
97
|
+
assert.strictEqual((0, signature_1.computePackDigest)(dir), walk);
|
|
98
|
+
assert.strictEqual((0, signature_1.computePackDigestFromFiles)((0, reconcile_1.readDirAsFiles)(dir)), files);
|
|
99
|
+
});
|
|
100
|
+
test('a symlink and a regular file with the same target string are distinguishable', () => {
|
|
101
|
+
const a = fs.mkdtempSync(path.join(os.tmpdir(), 'digest-link-a-'));
|
|
102
|
+
fs.symlinkSync('target.txt', path.join(a, 'x'));
|
|
103
|
+
const b = fs.mkdtempSync(path.join(os.tmpdir(), 'digest-link-b-'));
|
|
104
|
+
fs.writeFileSync(path.join(b, 'x'), 'target.txt');
|
|
105
|
+
assert.notStrictEqual((0, signature_1.computePackDigest)(a), (0, signature_1.computePackDigest)(b));
|
|
106
|
+
});
|
|
107
|
+
test('the exclusion predicate is one function, applied to relative paths', () => {
|
|
108
|
+
assert.ok((0, signature_1.isDigestExcluded)(signature_1.PACK_SIGNATURE_FILE));
|
|
109
|
+
assert.ok((0, signature_1.isDigestExcluded)('.git/HEAD'));
|
|
110
|
+
assert.ok((0, signature_1.isDigestExcluded)('a/node_modules/b'));
|
|
111
|
+
assert.ok(!(0, signature_1.isDigestExcluded)('.claude/settings.json'));
|
|
112
|
+
assert.ok(!(0, signature_1.isDigestExcluded)('.codex/config.toml'));
|
|
113
|
+
assert.ok(!(0, signature_1.isDigestExcluded)('.agent-hippo/x'));
|
|
114
|
+
assert.ok(!(0, signature_1.isDigestExcluded)('.DS_Store'));
|
|
115
|
+
// `pack.sig` is only special at the ROOT — a file of that name deeper in is content.
|
|
116
|
+
assert.ok(!(0, signature_1.isDigestExcluded)(`skills/${signature_1.PACK_SIGNATURE_FILE}`));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
suite('the cheap integrity probe', () => {
|
|
120
|
+
test('it moves when content changes, and is stable when nothing does', () => {
|
|
121
|
+
const dir = makeTree();
|
|
122
|
+
const before = (0, signature_1.packTreeProbe)(dir);
|
|
123
|
+
assert.strictEqual((0, signature_1.packTreeProbe)(dir), before, 'a stat-only probe must be deterministic');
|
|
124
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), 'name: demo\nchanged: true\n');
|
|
125
|
+
assert.notStrictEqual((0, signature_1.packTreeProbe)(dir), before);
|
|
126
|
+
});
|
|
127
|
+
test('it moves when a file is ADDED, which a per-file check would miss', () => {
|
|
128
|
+
const dir = makeTree();
|
|
129
|
+
const before = (0, signature_1.packTreeProbe)(dir);
|
|
130
|
+
fs.writeFileSync(path.join(dir, '.claude', 'hooks.json'), '{}');
|
|
131
|
+
assert.notStrictEqual((0, signature_1.packTreeProbe)(dir), before);
|
|
132
|
+
});
|
|
133
|
+
test('it ignores the same subtrees the digest does', () => {
|
|
134
|
+
const dir = makeTree();
|
|
135
|
+
const before = (0, signature_1.packTreeProbe)(dir);
|
|
136
|
+
fs.mkdirSync(path.join(dir, 'node_modules', 'new'), { recursive: true });
|
|
137
|
+
fs.writeFileSync(path.join(dir, 'node_modules', 'new', 'i.js'), 'x');
|
|
138
|
+
assert.strictEqual((0, signature_1.packTreeProbe)(dir), before);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
/**
|
|
40
|
+
* Converging on a pinned pack version. This is the path that fetches code over a network and then
|
|
41
|
+
* runs it, so every refusal below is load-bearing.
|
|
42
|
+
*/
|
|
43
|
+
require("mocha");
|
|
44
|
+
const assert = __importStar(require("assert"));
|
|
45
|
+
const fs = __importStar(require("node:fs"));
|
|
46
|
+
const os = __importStar(require("node:os"));
|
|
47
|
+
const path = __importStar(require("node:path"));
|
|
48
|
+
const warrant_1 = require("../authority/warrant");
|
|
49
|
+
const signature_1 = require("../pack/signature");
|
|
50
|
+
const reconcile_1 = require("../pack/reconcile");
|
|
51
|
+
const key = (0, warrant_1.generateWarrantKeypair)('rel-1');
|
|
52
|
+
const keys = { 'rel-1': key.publicKeyPem };
|
|
53
|
+
/** Build the file list a store download would return, signed unless told otherwise. */
|
|
54
|
+
function artifact(pack, version, opts = {}) {
|
|
55
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-'));
|
|
56
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), `name: ${pack}\nversion: ${version}\n`);
|
|
57
|
+
for (const [rel, body] of Object.entries(opts.extra ?? {})) {
|
|
58
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
60
|
+
}
|
|
61
|
+
if (opts.sign !== false) {
|
|
62
|
+
(0, signature_1.signPack)({ packDir: dir, pack, packVersion: version, privateKeyPem: key.privateKeyPem, kid: key.kid });
|
|
63
|
+
}
|
|
64
|
+
const files = [];
|
|
65
|
+
const walk = (d, prefix) => {
|
|
66
|
+
for (const name of fs.readdirSync(d)) {
|
|
67
|
+
const abs = path.join(d, name);
|
|
68
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
69
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
70
|
+
walk(abs, rel);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
files.push({ path: rel, content: fs.readFileSync(abs) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(dir, '');
|
|
78
|
+
return files;
|
|
79
|
+
}
|
|
80
|
+
const packsRoot = () => path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'packsroot-')), 'packs');
|
|
81
|
+
suite('pinned pack reconcile', () => {
|
|
82
|
+
test('fetches, verifies and promotes a signed version', async () => {
|
|
83
|
+
const root = packsRoot();
|
|
84
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
85
|
+
pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root,
|
|
86
|
+
download: async () => artifact('demo', '2.0.0'), publicKeysByKid: keys,
|
|
87
|
+
});
|
|
88
|
+
assert.ok(res.ok, res.ok ? '' : res.reason);
|
|
89
|
+
assert.equal(fs.realpathSync(path.join(root, reconcile_1.CURRENT_LINK)), fs.realpathSync(path.join(root, '2.0.0')));
|
|
90
|
+
assert.ok(fs.existsSync(path.join(root, '2.0.0', signature_1.PACK_SIGNATURE_FILE)));
|
|
91
|
+
});
|
|
92
|
+
// Fetching implies verifying: this is the whole reason signing landed first.
|
|
93
|
+
test('refuses an unsigned artifact and promotes nothing', async () => {
|
|
94
|
+
const root = packsRoot();
|
|
95
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
96
|
+
pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root,
|
|
97
|
+
download: async () => artifact('demo', '2.0.0', { sign: false }), publicKeysByKid: keys,
|
|
98
|
+
});
|
|
99
|
+
assert.equal(res.ok, false);
|
|
100
|
+
assert.match(res.reason, /not signed/);
|
|
101
|
+
assert.equal(fs.existsSync(path.join(root, reconcile_1.CURRENT_LINK)), false);
|
|
102
|
+
assert.equal(fs.existsSync(path.join(root, '2.0.0')), false);
|
|
103
|
+
});
|
|
104
|
+
test('refuses an artifact signed by an untrusted key', async () => {
|
|
105
|
+
const root = packsRoot();
|
|
106
|
+
const other = (0, warrant_1.generateWarrantKeypair)('attacker');
|
|
107
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
108
|
+
pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root,
|
|
109
|
+
download: async () => {
|
|
110
|
+
const files = artifact('demo', '2.0.0');
|
|
111
|
+
// Re-sign the same content with a key nobody trusts.
|
|
112
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resign-'));
|
|
113
|
+
for (const f of files.filter((x) => x.path !== signature_1.PACK_SIGNATURE_FILE)) {
|
|
114
|
+
fs.mkdirSync(path.dirname(path.join(dir, f.path)), { recursive: true });
|
|
115
|
+
fs.writeFileSync(path.join(dir, f.path), f.content);
|
|
116
|
+
}
|
|
117
|
+
(0, signature_1.signPack)({ packDir: dir, pack: 'demo', packVersion: '2.0.0', privateKeyPem: other.privateKeyPem, kid: other.kid });
|
|
118
|
+
return files.filter((x) => x.path !== signature_1.PACK_SIGNATURE_FILE)
|
|
119
|
+
.concat([{ path: signature_1.PACK_SIGNATURE_FILE, content: fs.readFileSync(path.join(dir, signature_1.PACK_SIGNATURE_FILE)) }]);
|
|
120
|
+
},
|
|
121
|
+
publicKeysByKid: keys,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(res.ok, false);
|
|
124
|
+
assert.match(res.reason, /unknown signing key/);
|
|
125
|
+
assert.equal(fs.existsSync(path.join(root, reconcile_1.CURRENT_LINK)), false);
|
|
126
|
+
});
|
|
127
|
+
// An empty keyring must refuse, or "verification" would accept anything at all.
|
|
128
|
+
test('refuses when no trusted keys are configured', async () => {
|
|
129
|
+
const root = packsRoot();
|
|
130
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
131
|
+
pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root,
|
|
132
|
+
download: async () => artifact('demo', '2.0.0'), publicKeysByKid: {},
|
|
133
|
+
});
|
|
134
|
+
assert.equal(res.ok, false);
|
|
135
|
+
assert.match(res.reason, /no trusted signing keys/);
|
|
136
|
+
});
|
|
137
|
+
// A tarball entry is untrusted input, and extraction happens BEFORE verification.
|
|
138
|
+
test('refuses an artifact whose entry escapes the pack root', async () => {
|
|
139
|
+
const root = packsRoot();
|
|
140
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
141
|
+
pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root,
|
|
142
|
+
download: async () => [{ path: '../../escaped.txt', content: Buffer.from('pwned') }],
|
|
143
|
+
publicKeysByKid: keys,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(res.ok, false);
|
|
146
|
+
assert.match(res.reason, /escapes the pack root/);
|
|
147
|
+
assert.equal(fs.existsSync(path.join(path.dirname(root), 'escaped.txt')), false);
|
|
148
|
+
});
|
|
149
|
+
test('a fetch failure leaves the fleet on what it already runs', async () => {
|
|
150
|
+
const root = packsRoot();
|
|
151
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
152
|
+
pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root,
|
|
153
|
+
download: async () => { throw new Error('502 from the store'); }, publicKeysByKid: keys,
|
|
154
|
+
});
|
|
155
|
+
assert.equal(res.ok, false);
|
|
156
|
+
assert.match(res.reason, /502 from the store/);
|
|
157
|
+
assert.equal(fs.existsSync(path.join(root, reconcile_1.CURRENT_LINK)), false);
|
|
158
|
+
});
|
|
159
|
+
test('a cached version is reused without re-downloading, but is re-verified', async () => {
|
|
160
|
+
const root = packsRoot();
|
|
161
|
+
let downloads = 0;
|
|
162
|
+
const download = async () => { downloads++; return artifact('demo', '2.0.0'); };
|
|
163
|
+
await (0, reconcile_1.reconcilePinnedPack)({ pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root, download, publicKeysByKid: keys });
|
|
164
|
+
const second = await (0, reconcile_1.reconcilePinnedPack)({ pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root, download, publicKeysByKid: keys });
|
|
165
|
+
assert.ok(second.ok);
|
|
166
|
+
assert.equal(second.ok && second.alreadyPresent, true);
|
|
167
|
+
assert.equal(downloads, 1);
|
|
168
|
+
// Now tamper with the cached copy: local disk is exactly where something could edit it.
|
|
169
|
+
fs.appendFileSync(path.join(root, '2.0.0', 'agent.yaml'), '# edited\n');
|
|
170
|
+
const third = await (0, reconcile_1.reconcilePinnedPack)({ pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root, download, publicKeysByKid: keys });
|
|
171
|
+
assert.ok(third.ok, third.ok ? '' : third.reason);
|
|
172
|
+
assert.equal(downloads, 2, 'a tampered cache must be re-fetched, not trusted');
|
|
173
|
+
});
|
|
174
|
+
test('promotion swaps the symlink so new sandboxes see the new version', async () => {
|
|
175
|
+
const root = packsRoot();
|
|
176
|
+
await (0, reconcile_1.reconcilePinnedPack)({ pack: 'demo', currentVersion: '0', pinnedVersion: '1.0.0', packsRoot: root, download: async () => artifact('demo', '1.0.0'), publicKeysByKid: keys });
|
|
177
|
+
assert.equal(fs.realpathSync(path.join(root, reconcile_1.CURRENT_LINK)), fs.realpathSync(path.join(root, '1.0.0')));
|
|
178
|
+
await (0, reconcile_1.reconcilePinnedPack)({ pack: 'demo', currentVersion: '1.0.0', pinnedVersion: '2.0.0', packsRoot: root, download: async () => artifact('demo', '2.0.0'), publicKeysByKid: keys });
|
|
179
|
+
assert.equal(fs.realpathSync(path.join(root, reconcile_1.CURRENT_LINK)), fs.realpathSync(path.join(root, '2.0.0')));
|
|
180
|
+
// The old version stays on disk: a rollback to it must not need the network again.
|
|
181
|
+
assert.ok(fs.existsSync(path.join(root, '1.0.0')));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
require("mocha");
|
|
40
|
+
const assert = __importStar(require("assert"));
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const os = __importStar(require("node:os"));
|
|
43
|
+
const path = __importStar(require("node:path"));
|
|
44
|
+
const warrant_1 = require("../authority/warrant");
|
|
45
|
+
const signature_1 = require("../pack/signature");
|
|
46
|
+
const lease_1 = require("../pack/lease");
|
|
47
|
+
const reconcile_1 = require("../pack/reconcile");
|
|
48
|
+
suite('pack signature artifact verification', () => {
|
|
49
|
+
test('artifact digest changes when excluded-path content is in the fetch list', () => {
|
|
50
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-artifact-'));
|
|
51
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), 'name: demo\n');
|
|
52
|
+
const signedDigest = (0, signature_1.computePackDigest)(dir);
|
|
53
|
+
const cleanFiles = [{ path: 'agent.yaml', content: Buffer.from('name: demo\n') }];
|
|
54
|
+
const tampered = [...cleanFiles, { path: '.claude/evil.sh', content: Buffer.from('#!/bin/sh\n') }];
|
|
55
|
+
assert.strictEqual((0, signature_1.computePackDigestFromFiles)(cleanFiles), signedDigest);
|
|
56
|
+
assert.notStrictEqual((0, signature_1.computePackDigestFromFiles)(tampered), signedDigest);
|
|
57
|
+
});
|
|
58
|
+
test('reconcile refuses artifact with tampered excluded-path content', async () => {
|
|
59
|
+
const key = (0, warrant_1.generateWarrantKeypair)('artifact-test');
|
|
60
|
+
const keys = { 'artifact-test': key.publicKeyPem };
|
|
61
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'signed-pack-'));
|
|
62
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), 'name: demo\nversion: 2.0.0\n');
|
|
63
|
+
(0, signature_1.signPack)({ packDir: dir, pack: 'demo', packVersion: '2.0.0', privateKeyPem: key.privateKeyPem, kid: key.kid });
|
|
64
|
+
const files = [];
|
|
65
|
+
const walk = (d, prefix) => {
|
|
66
|
+
for (const name of fs.readdirSync(d)) {
|
|
67
|
+
const abs = path.join(d, name);
|
|
68
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
69
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
70
|
+
walk(abs, rel);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
files.push({ path: rel, content: fs.readFileSync(abs) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(dir, '');
|
|
78
|
+
files.push({ path: '.claude/evil.sh', content: Buffer.from('#!/bin/sh\necho pwned\n') });
|
|
79
|
+
const root = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'packsroot-')), 'packs');
|
|
80
|
+
const res = await (0, reconcile_1.reconcilePinnedPack)({
|
|
81
|
+
pack: 'demo',
|
|
82
|
+
currentVersion: '1.0.0',
|
|
83
|
+
pinnedVersion: '2.0.0',
|
|
84
|
+
packsRoot: root,
|
|
85
|
+
download: async () => files,
|
|
86
|
+
publicKeysByKid: keys,
|
|
87
|
+
});
|
|
88
|
+
assert.equal(res.ok, false);
|
|
89
|
+
assert.match(res.reason, /changed after signing/);
|
|
90
|
+
assert.equal(fs.existsSync(path.join(root, '2.0.0')), false);
|
|
91
|
+
assert.ok(!files.some((f) => f.path === signature_1.PACK_SIGNATURE_FILE) || true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
suite('serve admission active version', () => {
|
|
95
|
+
test('pinned lease admits activeVersion after converge, not boot version', () => {
|
|
96
|
+
const now = Math.floor(Date.now() / 1000);
|
|
97
|
+
const lease = { pack: 'demo', version: '2.0.0', expiresAt: now + 60, enabled: true };
|
|
98
|
+
const bootVersion = '1.0.0';
|
|
99
|
+
const activeVersion = '2.0.0';
|
|
100
|
+
assert.doesNotThrow(() => (0, lease_1.assertAdmitted)(lease, { pack: 'demo', version: activeVersion, nowSeconds: now }));
|
|
101
|
+
assert.throws(() => (0, lease_1.assertAdmitted)(lease, { pack: 'demo', version: bootVersion, nowSeconds: now }), /pinned to 2.0.0/);
|
|
102
|
+
});
|
|
103
|
+
});
|