Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/canonical-control-offline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ jobs:
with:
script: |
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),assert=require('node:assert/strict');
const {owner,repo}=context.repo,head=process.env.SOURCE_HEAD,reviewed='c42ce5a5a30c4b7aa7981d86b12831928e30f9cb';assert.match(head||'',/^[a-f0-9]{40}$/);
const {owner,repo}=context.repo,head=process.env.SOURCE_HEAD,reviewed='1b11a3f55d761c41aa60ca0b4a3ef4485c2f0937';assert.match(head||'',/^[a-f0-9]{40}$/);
async function treeFor(sha){const {data:c}=await github.rest.git.getCommit({owner,repo,commit_sha:sha});assert.equal(c.sha,sha);const {data:t}=await github.rest.git.getTree({owner,repo,tree_sha:c.tree.sha,recursive:'true'});assert.equal(t.truncated,false);assert.equal(t.sha,c.tree.sha);return t;}
const tree=await treeFor(head),baseline=await treeFor(reviewed);assert.equal(baseline.sha,'9e3e1b9693e7f79a800dc15cc3e7b515dfe7bb23');
const tree=await treeFor(head),baseline=await treeFor(reviewed);assert.equal(baseline.sha,'26705201071c014805979e9f093fefde4dee99ae');
const proof={source_head:head,tree:tree.sha,reviewed_head:reviewed,expected_github_api_calls:282,sources:[]},root=fs.mkdtempSync(path.join(fs.realpathSync(process.env.RUNNER_TEMP),'cortex-feedback-'));
core.exportVariable('CORTEX_FEEDBACK_ROOT',root);core.info('Expected GitHub API requests: 282; any API failure fails closed; no alternate credentials or downloader');fs.chmodSync(root,0o700);for(const dir of ['source','fixtures'])fs.mkdirSync(path.join(root,dir),{mode:0o700});
async function bytesFor(relative){
Expand Down
11 changes: 8 additions & 3 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ async function initModeGeneral() {
await upgrade(l1Ctx);
// Respect --dry-run: state-githooks installer copies templates to disk,
// which would violate the zero-write contract. Skip when dryRun is set.
if (!l1Ctx.options.dryRun) installStateGithooks({ cwd, lang });
if (!l1Ctx.options.dryRun && !process.exitCode) installStateGithooks({ cwd, lang });
break;
case "update":
// Help must be a zero-write operation. In particular, do not enter
Expand All @@ -677,7 +677,7 @@ async function initModeGeneral() {
await upgrade(l1Ctx);
// Respect --dry-run: state-githooks installer copies templates to disk,
// which would violate the zero-write contract. Skip when dryRun is set.
if (!l1Ctx.options.dryRun) installStateGithooks({ cwd, lang });
if (!l1Ctx.options.dryRun && !process.exitCode) installStateGithooks({ cwd, lang });
break;
case "track": trackAgent(ctx); break;
case "untrack": untrackAgent(ctx); break;
Expand All @@ -693,7 +693,11 @@ async function initModeGeneral() {
case "bridge": bridge(l1Ctx); break;
case "topology": topology(l1Ctx); break;
case "automation": automation(l1Ctx); break;
case "local-publish-validate": localPublishValidate(ctx); break;
case "local-publish-validate": {
const code = localPublishValidate(ctx);
if (code !== 0) process.exitCode = code;
break;
}
case "proposal-share": proposalShare(ctx); break;
case "skill": skillBrowse(ctx); break;
case "runs": runs(ctx); break;
Expand Down Expand Up @@ -748,6 +752,7 @@ async function initModeGeneral() {
break;
}
case "lease": lease(ctx); break;
case "permit": await require("../lib/commands/permit-managed-cli").permitManagedCli(ctx); break;
case "notification": await notification(ctx); break;
case "mcp": await mcpCommand(ctx); break;
case "motion": await motionCommand(ctx); break;
Expand Down
1 change: 1 addition & 0 deletions lib/cli/contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const commands = [
command("task", "task <create|assign|accept|progress|heartbeat|test|ready|complete|block|request-input|cancel|takeover|status|list|watch> [options]", "Read or update Coordination Tasks through the Coordination Application Service. create/assign/accept accept restricted fields; raw event envelopes remain a legacy compatibility path."),
command("event", "event <list|ack> [options]", "List coordination events or acknowledge delivery without changing task state."),
command("lease", "lease <acquire|renew|release|status|recover> [options]", "Acquire and manage fenced task ownership leases through the durable Coordination LeaseManager. It never starts a host or creates a task.", { mode: "coordination_lease", restricted: true }),
command("permit", "permit <issue|register|key|verify|revoke> --input <json-file> [--output <file>]", "Permit operations require an authenticated managed session; direct invocation is refused. Host integration and deployment validation remain pending.", { mode: "managed_session", restricted: true, pending_integration: false }),
command("notification", "notification <pump> [options]", "Run, watch, inspect, or stop the Agent Coordination Notification Pump against an adapter whitelist. Never accepts event.command / event.executable from the CLI surface.", { mode: "adapter", thin: true, pending_integration: false }),
command("runs", "runs <list|show|upsert|event|checkpoint|tokens|transcript-link>", "Read or update Run state through explicit actions."),
command("queues", "queues <list|upsert|item>", "Read or update Queue state through explicit actions."),
Expand Down
140 changes: 140 additions & 0 deletions lib/commands/permit-managed-cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"use strict";

const fs = require("node:fs");
const path = require("node:path");
const { CHANNEL_MESSAGE, SIGNED_MESSAGE_KIND } = require("../governed/managed-caller-session");
const { readSessionSecret, createExecutorEndpoint } = require("../governed/managed-caller-supervisor");
const { signResponse } = require("../governed/managed-caller-signing");

const ACTIONS = new Set(["issue", "register", "key", "verify", "revoke"]);
const USAGE = "Usage: cortex-agent permit <issue|register|key|verify|revoke> --input <json-file> [--output <file>] (managed session required)";

function parseArgs(args) {
const action = args[1];
if (!ACTIONS.has(action)) throw new Error("invalid arguments");
const options = {};
for (let i = 2; i < args.length; i += 2) {
const key = args[i];
if (!["--input", "--output"].includes(key) || options[key] !== undefined ||
typeof args[i + 1] !== "string" || args[i + 1].startsWith("--")) throw new Error("invalid arguments");
options[key] = args[i + 1];
}
if (!options["--input"] || (action === "issue") !== Boolean(options["--output"])) throw new Error("invalid arguments");
return { action, input: options["--input"], output: options["--output"] };
}

function readInput(file) {
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
let raw;
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("invalid input");
raw = fs.readFileSync(fd, "utf8");
} finally { fs.closeSync(fd); }
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(raw)) throw new Error("private material refused");
const value = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid input");
return value;
}

function writeIssue(fd, result) {
if (!result || typeof result !== "object" || !result.signedPermit ||
typeof result.fingerprint !== "string" || typeof result.publicKeyPem !== "string" ||
/PRIVATE KEY|privateKeyRef/.test(JSON.stringify(result))) throw new Error("invalid result");
fs.writeFileSync(fd, JSON.stringify(result) + "\n");
fs.fsyncSync(fd);
}

function waitForSupervisorClose() {
if (!process.connected) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(done, 30000);
function done() {
clearTimeout(timer);
process.off("message", onMessage);
process.off("disconnect", done);
resolve();
}
function onMessage(message) {
if (message && message.type === CHANNEL_MESSAGE.BYE) done();
}
process.on("message", onMessage);
process.once("disconnect", done);
});
}

async function permitManagedCli(ctx) {
if (ctx.args.includes("--help") || ctx.args.includes("-h")) { console.log(USAGE); return; }
let options;
try { options = parseArgs(ctx.args); }
catch { console.error("permit: invalid arguments"); process.exitCode = 2; return; }
if (!process.channel || typeof process.send !== "function") {
console.error("permit: managed session required"); process.exitCode = 3; return;
}
let secret;
let endpoint;
let outputFd;
let started = false;
const queued = [];
const start = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("managed session timeout")), 30000);
function handle(message) {
if (!secret) {
if (queued.length >= 32) { clearTimeout(timer); reject(new Error("managed session refused")); return; }
queued.push(message);
return;
}
if (!message || typeof message !== "object") return;
if (message.type === CHANNEL_MESSAGE.HELLO) {
const body = { nonce: message.nonce };
process.send({ type: CHANNEL_MESSAGE.RESPONSE, requestId: message.requestId,
sessionId: message.sessionId, nonce: message.nonce, body,
mac: signResponse(secret, { kind: SIGNED_MESSAGE_KIND.HELLO, requestId: message.requestId,
seq: message.seq, operation: "hello", body }) });
} else if (!started && message.type === CHANNEL_MESSAGE.REQUEST && message.operation === "permit.start") {
try { endpoint.answer(message, { ready: true }); }
catch { clearTimeout(timer); reject(new Error("managed session refused")); return; }
started = true;
clearTimeout(timer);
process.off("message", handle);
resolve();
}
}
process.on("message", handle);
readSessionSecret(process).then((value) => {
secret = value;
endpoint = createExecutorEndpoint(process, secret);
for (const message of queued.splice(0)) handle(message);
}, () => { clearTimeout(timer); reject(new Error("managed session refused")); });
});
try {
await start;
const input = readInput(path.resolve(ctx.cwd, options.input));
// Reserve the output before issuing so an existing destination cannot
// cause a committed reservation whose result has nowhere to be written.
if (options.action === "issue") {
outputFd = fs.openSync(path.resolve(ctx.cwd, options.output), "wx", 0o600);
}
const result = await endpoint.request(`permit.${options.action}`, input);
if (/PRIVATE KEY|privateKeyRef/.test(JSON.stringify(result))) throw new Error("private material refused");
if (options.action === "issue") {
writeIssue(outputFd, result);
console.log(JSON.stringify({ ok: true, action: "issue", written: true }));
} else {
console.log(JSON.stringify({ ok: true, action: options.action, result }));
}
} catch {
console.error("permit: managed operation rejected");
process.exitCode = 3;
} finally {
if (outputFd !== undefined) fs.closeSync(outputFd);
if (started && process.connected) {
const close = waitForSupervisorClose();
process.send({ type: "permit.result-ready" });
await close;
}
if (process.connected) process.disconnect();
}
}

module.exports = { permitManagedCli };
24 changes: 21 additions & 3 deletions lib/coordination/cross-machine-permit.js
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ function pullAndApplyRevocation(options) {
pull,
nowMs,
pullTimeoutMs,
verifyPermit,
} = options;
if (!pinStore || typeof pinStore.criticalSection !== "function") {
fail("ERR_PERMIT_INPUT_INVALID");
Expand Down Expand Up @@ -465,11 +466,28 @@ function pullAndApplyRevocation(options) {
nowMs,
});

// The revoked set and the applied high-water mark advance together.
// On successful permit verification, commit its high-water mark with the
// freshly authenticated revocation state. A rejected permit still applies
// the valid revocation list, without advancing the permit high-water mark.
let verified;
let verificationError;
if (verifyPermit !== undefined) {
if (typeof verifyPermit !== "function") fail("ERR_PERMIT_INPUT_INVALID");
// Do not hand the verifier the state being committed: a callback must
// not be able to alter the authenticated revocation set after checking.
const verificationState = { ...state, revokedFingerprints: new Set(state.revokedFingerprints) };
try { verified = verifyPermit(verificationState); } catch (error) { verificationError = error; }
if (!verificationError && (!verified || verified.ok !== true || !Number.isSafeInteger(verified.sequence))) {
verificationError = new CoordinationError("ERR_PERMIT_INPUT_INVALID");
}
}
pinStore.commitRevocation({
sequence,
revokedFingerprints: list.revoked_fingerprints.slice().sort(),
sequence: applied.appliedRevocationSequence,
revokedFingerprints: [...state.revokedFingerprints].sort(),
...(!verificationError && verified ? { permitSequence: verified.sequence } : {}),
});
if (verificationError) throw verificationError;
if (verifyPermit !== undefined) return verified;
return applied;
});
}
Expand Down
48 changes: 48 additions & 0 deletions lib/coordination/permit-authority-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use strict";

const { CoordinationError } = require("./errors");

function freezeRequest(value) {
if (value && typeof value === "object") {
for (const child of Object.values(value)) freezeRequest(child);
Object.freeze(value);
}
return value;
}

// The transport owns peer authentication and must supply an opaque verified
// context; this function neither creates a listener nor authenticates a peer.
function createPermitAuthorityService({ authorize, batchSession, targetService } = {}) {
if (typeof authorize !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");

function dispatch(peer, action, args) {
if (!["issue", "register", "key", "verify", "revoke"].includes(action) ||
!args || typeof args !== "object" || Array.isArray(args)) {
throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
}
// Authorize and execute the same detached request, including its resource
// bindings. The host still has to compare those bindings to trusted state.
let request;
try { request = freezeRequest(JSON.parse(JSON.stringify(args))); }
catch { throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); }
// Promise-like and non-boolean results are deliberately not accepted.
let authorized;
try { authorized = authorize(peer, action, request); }
catch { throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); }
if (authorized !== true) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
if (action === "issue") {
if (!batchSession || typeof batchSession.issue !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
return batchSession.issue({ lease: request.lease, permit: request.permit });
}
if (!targetService || typeof targetService[action] !== "function") {
throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
}
if (action === "key") return targetService.key(request.fingerprint);
if (action === "revoke") return targetService.revoke();
return targetService[action](request);
}

return Object.freeze({ dispatch });
}

module.exports = { createPermitAuthorityService };
60 changes: 60 additions & 0 deletions lib/coordination/permit-batch-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use strict";

const delivery = require("./permit-delivery");
const { signRevocationList } = require("./permit-revocation-signing");
const { CoordinationError } = require("./errors");
const { validateReservationInput } = require("./protected-permit-store");

// Process-local primitive, not an authenticated service or a CLI endpoint.
// The caller owns the protected store, authorization and restart handling.
function createPermitBatchSession({ store, clock = Date.now } = {}) {
if (!store || typeof store.acquire !== "function" || typeof store.reserve !== "function" ||
typeof clock !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
let key = delivery.createBatchKeyPair();
delivery.assertNoPrivateKeyLeak({ privateKeyRef: key.privateKeyRef });

function currentKey() {
if (!key) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
return key;
}

function issue({ lease, permit } = {}) {
const batch = currentKey();
if (!lease || !permit || typeof lease !== "object" || typeof permit !== "object") {
throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
}
// An idempotent reserve may return a permit from an earlier batch.
// Never sign it unless the store atomically binds the batch identity.
if (typeof store.acquireAndReserveBoundToBatch !== "function") {
throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
}
const initialTime = clock();
if (!Number.isSafeInteger(initialTime) || initialTime < 0) throw new CoordinationError("ERR_CLOCK_INVALID");
validateReservationInput(permit);
return store.acquireAndReserveBoundToBatch(lease, permit, batch.fingerprint, (reserved) => {
const now = clock();
if (!Number.isSafeInteger(now) || now < 0) throw new CoordinationError("ERR_CLOCK_INVALID");
if (!reserved || reserved.status !== "reserved" || !Number.isSafeInteger(reserved.deadline)) {
throw new CoordinationError("ERR_PERMIT_INPUT_INVALID");
}
if (now >= reserved.deadline) throw new CoordinationError("ERR_PERMIT_DEADLINE_EXPIRED");
const signed = delivery.buildSignedPermit({ privateKeyRef: batch.privateKeyRef,
permit: reserved, issuedAtMs: now, nowMs: now });
return { fingerprint: batch.fingerprint, publicKeyPem: batch.publicKeyPem, signedPermit: signed };
});
}

function signRevocation({ state, fingerprints } = {}) {
const batch = currentKey();
return signRevocationList({ privateKeyRef: batch.privateKeyRef, publicKeyPem: batch.publicKeyPem,
state, fingerprints, issuedAtMs: clock() });
}

return Object.freeze({
issue,
signRevocation,
close() { key = null; },
});
}

module.exports = { createPermitBatchSession };
Loading
Loading