Skip to content

Commit a209777

Browse files
committed
cli/install: --tier {unattested|software|totp}; mint attested session via shared helper; bump v0.1.7
1 parent dc1b15b commit a209777

5 files changed

Lines changed: 198 additions & 27 deletions

File tree

Formula/agentlock.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
class Agentlock < Formula
22
desc "Locally-hosted, open-source firewall for AI coding agents"
33
homepage "https://openagentlock.github.io/OpenAgentLock"
4-
url "https://registry.npmjs.org/@openagentlock/cli/-/cli-0.1.6.tgz"
5-
sha256 "1c1fd00b9e9e6cce8f3fc34e15a9b2e8a6942d1db993a40382c217076c6918ca"
4+
url "https://registry.npmjs.org/@openagentlock/cli/-/cli-0.1.7.tgz"
5+
sha256 "REPLACE_AFTER_NPM_PUBLISH"
66
license "FSL-1.1-Apache-2.0"
7-
version "0.1.6"
7+
version "0.1.7"
88

99
depends_on "bun"
1010

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openagentlock/cli",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"type": "module",
55
"license": "SEE LICENSE IN LICENSE",
66
"description": "OpenAgentLock CLI — a firewall for AI coding agents. Detects local agent harnesses (Claude Code, Codex CLI, Cursor, OpenCode, Cline, Gemini CLI, Continue, Copilot), gates risky tool calls via a Go control plane, anchors decisions in a Rust Merkle ledger.",

cli/src/commands/install.ts

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { detectAll } from "../detect/index.ts";
3232
import type { HarnessId } from "../detect/types.ts";
3333
import { multiselect } from "../tui/multiselect.tsx";
3434
import { apiClient, type InstallFileOp } from "../util/api.ts";
35+
import { mintAttestedSession, type AttestedTier } from "../util/session-mint.ts";
3536

3637
// Source-tree default: cli/agentlock is a bash wrapper that does
3738
// `exec bun run cli/src/index.ts "$@"`, so harnesses can spawn
@@ -44,19 +45,33 @@ function defaultAgentlockBinary(): string {
4445
return process.execPath;
4546
}
4647

48+
type InstallTier = "unattested" | AttestedTier;
49+
4750
interface InstallFlags {
4851
yes: boolean;
4952
daemonUrl?: string;
5053
configDirOverride?: string;
54+
tier: InstallTier;
55+
totpCode?: string;
56+
totpPassphrase?: string;
5157
}
5258

5359
function parseFlags(argv: string[]): InstallFlags {
54-
const flags: InstallFlags = { yes: false };
60+
const flags: InstallFlags = { yes: false, tier: "unattested" };
5561
for (let i = 0; i < argv.length; i++) {
5662
const a = argv[i];
5763
if (a === "--yes" || a === "-y") flags.yes = true;
5864
else if (a === "--daemon" || a === "--daemon-url") flags.daemonUrl = argv[++i];
5965
else if (a === "--config-dir") flags.configDirOverride = argv[++i];
66+
else if (a === "--tier") {
67+
const v = argv[++i];
68+
if (v === "unattested" || v === "software" || v === "totp") {
69+
flags.tier = v;
70+
} else {
71+
throw new Error(`unknown --tier: ${v} (want unattested|software|totp)`);
72+
}
73+
} else if (a === "--code") flags.totpCode = argv[++i];
74+
else if (a === "--passphrase") flags.totpPassphrase = argv[++i];
6075
}
6176
// Resolve --config-dir to an absolute path against the CLI's CWD. The
6277
// daemon may be running with a different working directory (e.g. via
@@ -165,25 +180,61 @@ export async function runInstall(argv: string[] = []): Promise<void> {
165180
return;
166181
}
167182

168-
// 3. Unattested session mint (signer=none) ----------------------------
169-
process.stdout.write(`\nminting unattested session on ${api.baseUrl}...\n`);
183+
// 3. Session mint -----------------------------------------------------
184+
// Default is unattested (matches the "monitor mode default" posture
185+
// of the daemon). For prod use, callers pass --tier software|totp;
186+
// the daemon then accepts the install/uninstall flow under a real
187+
// signed session and ledger entries carry the strong signer banner.
170188
let sessionId: string;
171-
try {
172-
const sess = await api.createUnattestedSession();
173-
sessionId = sess.id;
174-
process.stdout.write(` session: ${sess.id} (${sess.banner ?? sess.signer})\n`);
175-
} catch (err) {
176-
const msg = (err as Error).message;
177-
if (msg.includes("unattested_disabled")) {
189+
if (flags.tier === "unattested") {
190+
process.stdout.write(`\nminting unattested session on ${api.baseUrl}...\n`);
191+
try {
192+
const sess = await api.createUnattestedSession();
193+
sessionId = sess.id;
194+
process.stdout.write(` session: ${sess.id} (${sess.banner ?? sess.signer})\n`);
195+
} catch (err) {
196+
const msg = (err as Error).message;
197+
if (msg.includes("unattested_disabled")) {
198+
process.stderr.write(
199+
"\nunattested sessions are disabled on this daemon.\n" +
200+
" - re-run with --tier totp (recommended for prod), or\n" +
201+
" - re-run with --tier software (dev only), or\n" +
202+
" - set AGENTLOCK_ALLOW_UNATTESTED=1 on the daemon (dev only).\n" +
203+
"see https://openagentlock.github.io/OpenAgentLock/guide/signers/ for the signer ladder.\n",
204+
);
205+
} else {
206+
process.stderr.write(`\nsession mint failed: ${msg}\n`);
207+
}
208+
process.exitCode = 2;
209+
return;
210+
}
211+
} else {
212+
if (flags.tier === "totp" && (!flags.totpCode || !flags.totpPassphrase)) {
178213
process.stderr.write(
179-
"\nunattested sessions are disabled on this daemon.\n" +
180-
"set AGENTLOCK_ALLOW_UNATTESTED=1 on the daemon to enable them for dev.\n",
214+
"\n--tier totp requires --code <6-digit> and --passphrase <pp>.\n" +
215+
"enroll first with: agentlock signer enroll --tier totp --passphrase <pp>\n",
181216
);
182-
} else {
217+
process.exitCode = 2;
218+
return;
219+
}
220+
process.stdout.write(
221+
`\nminting attested session (tier=${flags.tier}) on ${api.baseUrl}...\n`,
222+
);
223+
try {
224+
const sess = await mintAttestedSession({
225+
tier: flags.tier,
226+
url: flags.daemonUrl,
227+
code: flags.totpCode,
228+
passphrase: flags.totpPassphrase,
229+
});
230+
sessionId = sess.id;
231+
process.stdout.write(` session: ${sess.id} (signer=${sess.signer})\n`);
232+
} catch (err) {
233+
const msg = (err as Error).message;
183234
process.stderr.write(`\nsession mint failed: ${msg}\n`);
235+
process.exitCode = 2;
236+
return;
184237
}
185-
process.exitCode = 2;
186-
return;
187238
}
188239

189240
const daemonUrl = flags.daemonUrl ?? api.baseUrl;

cli/src/index.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ program
4747
program
4848
.command("install")
4949
.description(
50-
"Interactive selector. Detects harnesses, mints an unattested session, fetches the install plan, confirms with you, then POSTs /v1/install/apply.",
50+
"Interactive selector. Detects harnesses, mints a session (default: unattested), fetches the install plan, confirms with you, then POSTs /v1/install/apply.",
5151
)
5252
.option("-y, --yes", "Skip the apply confirmation prompt.")
5353
.option(
@@ -58,13 +58,34 @@ program
5858
"--config-dir <path>",
5959
"Override the per-harness config directory (e.g. ./dev/.claude for dev runs). Default: the harness's real user home path.",
6060
)
61-
.action(async (opts: { yes?: boolean; daemon?: string; configDir?: string }) => {
62-
const argv: string[] = [];
63-
if (opts.yes) argv.push("--yes");
64-
if (opts.daemon) argv.push("--daemon", opts.daemon);
65-
if (opts.configDir) argv.push("--config-dir", opts.configDir);
66-
await runInstall(argv);
67-
});
61+
.option(
62+
"--tier <tier>",
63+
"Session signer tier: unattested (default; daemon must allow it), software (dev/CI), or totp (prod, requires prior `agentlock signer enroll --tier totp`).",
64+
)
65+
.option("--code <code>", "TOTP 6-digit code (required when --tier totp).")
66+
.option(
67+
"--passphrase <pp>",
68+
"Passphrase used at enrollment (required when --tier totp).",
69+
)
70+
.action(
71+
async (opts: {
72+
yes?: boolean;
73+
daemon?: string;
74+
configDir?: string;
75+
tier?: string;
76+
code?: string;
77+
passphrase?: string;
78+
}) => {
79+
const argv: string[] = [];
80+
if (opts.yes) argv.push("--yes");
81+
if (opts.daemon) argv.push("--daemon", opts.daemon);
82+
if (opts.configDir) argv.push("--config-dir", opts.configDir);
83+
if (opts.tier) argv.push("--tier", opts.tier);
84+
if (opts.code) argv.push("--code", opts.code);
85+
if (opts.passphrase) argv.push("--passphrase", opts.passphrase);
86+
await runInstall(argv);
87+
},
88+
);
6889

6990
program
7091
.command("dashboard")

cli/src/util/session-mint.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Shared helper for minting an *attested* session against a control
2+
// plane. Used by `agentlock session create` and by `agentlock install`
3+
// when invoked with `--tier software|totp` so the install/uninstall
4+
// flow runs under a real signed session instead of forcing the daemon
5+
// into AGENTLOCK_ALLOW_UNATTESTED.
6+
//
7+
// Layout mirrors what `runSessionCreate` used to do inline:
8+
// 1. Load (or create) the long-lived signer for the requested tier.
9+
// 2. Generate / reuse the session keypair under $AGENTLOCK_HOME.
10+
// 3. Canonicalize + sign the attestation.
11+
// 4. POST /v1/sessions/create.
12+
//
13+
// On success the on-disk `session-current.key` is what subsequent calls
14+
// (gates, ledger appends) sign with — which is why it's persisted.
15+
16+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17+
import { join } from "node:path";
18+
19+
import * as ed25519 from "@noble/ed25519";
20+
21+
import { apiClient, type SessionResponse, type SessionStartRequest } from "./api";
22+
import { agentlockHome } from "./paths";
23+
import { loadOrCreateSoftwareSigner } from "../signer/software";
24+
import { loadTOTPSigner } from "../signer/totp";
25+
import { canonicalAttestation } from "../signer/canonical";
26+
import type { Signer, SignerKind } from "../signer/types";
27+
28+
export type AttestedTier = "software" | "totp";
29+
30+
export interface MintAttestedOptions {
31+
tier: AttestedTier;
32+
url?: string;
33+
policyHash?: string;
34+
// tier=totp only:
35+
code?: string;
36+
passphrase?: string;
37+
}
38+
39+
export async function mintAttestedSession(
40+
opts: MintAttestedOptions,
41+
): Promise<SessionResponse> {
42+
const home = agentlockHome();
43+
mkdirSync(home, { recursive: true });
44+
45+
let signer: Signer;
46+
let signerKind: SignerKind;
47+
if (opts.tier === "software") {
48+
signer = await loadOrCreateSoftwareSigner(home);
49+
signerKind = "software";
50+
} else {
51+
if (!opts.code || !opts.passphrase) {
52+
throw new Error("tier=totp requires --code and --passphrase");
53+
}
54+
signer = await loadTOTPSigner(home, { code: opts.code, passphrase: opts.passphrase });
55+
signerKind = "totp_backed_software";
56+
}
57+
58+
const sessionKeyPath = join(home, "session-current.key");
59+
let sessionSeed: Uint8Array;
60+
if (existsSync(sessionKeyPath)) {
61+
sessionSeed = new Uint8Array(readFileSync(sessionKeyPath));
62+
} else {
63+
sessionSeed = new Uint8Array(32);
64+
crypto.getRandomValues(sessionSeed);
65+
writeFileSync(sessionKeyPath, sessionSeed, { mode: 0o600 });
66+
}
67+
const sessionPub = await ed25519.getPublicKeyAsync(sessionSeed);
68+
69+
const policyHash = opts.policyHash ?? readPolicyHash(home) ?? "sha256:0000";
70+
const payload = {
71+
policy_hash: policyHash,
72+
session_pubkey: `ed25519:${toHex(sessionPub)}`,
73+
signer: signerKind,
74+
signer_pubkey: `ed25519:${toHex(signer.publicKey)}`,
75+
};
76+
const canon = new TextEncoder().encode(canonicalAttestation(payload));
77+
const attestation = await signer.sign(canon);
78+
79+
const req: SessionStartRequest = {
80+
policy_hash: payload.policy_hash,
81+
session_pubkey: payload.session_pubkey,
82+
signer: payload.signer,
83+
signer_pubkey: payload.signer_pubkey,
84+
attestation: `ed25519:${toHex(attestation)}`,
85+
};
86+
87+
const client = apiClient(opts.url);
88+
return client.createSession(req);
89+
}
90+
91+
function readPolicyHash(home: string): string | null {
92+
const p = join(home, "policy.hash");
93+
if (!existsSync(p)) return null;
94+
return readFileSync(p, "utf8").trim();
95+
}
96+
97+
function toHex(b: Uint8Array): string {
98+
return Array.from(b, (v) => v.toString(16).padStart(2, "0")).join("");
99+
}

0 commit comments

Comments
 (0)