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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ resolver = "3"
rust-version = "1.93"
readme = "README.md"
authors = ["HAI.AI <engineering@hai.io>"]
license = "Apache-2.0 OR MIT"
license = "Apache-2.0"
homepage = "https://humanassisted.github.io/JACS"
repository = "https://github.com/HumanAssisted/JACS"
keywords = ["cryptography", "json", "ai", "data", "ml-ops"]
Expand Down
4 changes: 2 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
This project is dual-licensed under Apache-2.0 OR MIT, at your option.
This project is licensed under Apache-2.0.

See LICENSE-APACHE and LICENSE-MIT for details.
See LICENSE-APACHE for details.

Copyright 2024, 2025, 2026 Human Assisted Intelligence, PBC
21 changes: 0 additions & 21 deletions LICENSE-MIT

This file was deleted.

12 changes: 6 additions & 6 deletions LINES_OF_CODE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Language Files Lines Code Comments Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Go 13 5859 4344 708 807
Python 188 46396 36376 2037 7983
Go 16 6521 4827 813 881
Python 191 46952 36823 2064 8065
TypeScript 30 9105 6380 2000 725
─────────────────────────────────────────────────────────────────────────────────
Rust 258 106330 87477 6054 12799
|- Markdown 213 9703 491 7231 1981
(Total) 116033 87968 13285 14780
Rust 263 109477 90153 6190 13134
|- Markdown 218 9920 496 7407 2017
(Total) 119397 90649 13597 15151
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total 489 177393 135068 18030 24295
Total 500 181975 138679 18474 24822
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,4 @@ Report vulnerabilities to security@hai.ai. Do not open public issues for securit

---

v0.9.7 | [Apache-2.0 OR MIT](./LICENSE-APACHE) | [Third-Party Notices](./THIRD-PARTY-NOTICES)
v0.9.7 | [Apache-2.0](./LICENSE-APACHE) | [Third-Party Notices](./THIRD-PARTY-NOTICES)
46 changes: 23 additions & 23 deletions THIRD-PARTY-NOTICES
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ THIRD-PARTY SOFTWARE NOTICES AND INFORMATION

JACS incorporates third-party software components. The following notices
are provided for informational purposes. JACS is licensed under
Apache-2.0 OR MIT (see LICENSE-APACHE and LICENSE-MIT).
Apache-2.0 (see LICENSE-APACHE).

===============================================================================

Expand Down Expand Up @@ -679,28 +679,28 @@ THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO
-------------------------------------------------------------------------------
--- MIT ---

The MIT License (MIT)
Copyright (c) 2016 Johann Tuffe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
The MIT License (MIT)

Copyright (c) 2016 Johann Tuffe

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:


The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.


THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

-------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion binding-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ resolver = "3"
description = "Shared core logic for JACS language bindings (Python, Node.js, etc.)"
readme = "../README.md"
authors = ["JACS Contributors"]
license = "Apache-2.0 OR MIT"
license = "Apache-2.0"
repository = "https://github.com/HumanAssisted/JACS"
homepage = "https://humanassisted.github.io/JACS"

Expand Down
119 changes: 116 additions & 3 deletions binding-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ use reqwest::{StatusCode, Url};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
Expand Down Expand Up @@ -234,6 +238,32 @@ fn read_password_file(path: &Path) -> BindingResult<Option<String>> {
return Ok(None);
}

#[cfg(unix)]
{
let metadata = fs::metadata(path).map_err(|e| {
BindingCoreError::generic(format!(
"Failed to inspect password file {}: {}",
path.display(),
e
))
})?;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(BindingCoreError::generic(format!(
"Password file {} has insecure permissions (mode {:04o}). \
File must not be group-readable or world-readable.",
path.display(),
mode
)));
}
if !metadata.is_file() {
return Err(BindingCoreError::generic(format!(
"Password file {} is not a regular file.",
path.display()
)));
}
}

let contents = fs::read_to_string(path).map_err(|e| {
BindingCoreError::generic(format!(
"Failed to read password file {}: {}",
Expand Down Expand Up @@ -529,18 +559,38 @@ fn persist_password_file(key_directory: &Path, password: &str) -> BindingResult<
})?;

let password_path = key_directory.join(".jacs_password");
fs::write(&password_path, password).map_err(|e| {
let mut options = OpenOptions::new();
options.write(true).create_new(true);

#[cfg(unix)]
{
options.mode(0o600);
}

let mut file = options.open(&password_path).map_err(|e| {
BindingCoreError::generic(format!(
"Failed to create password file {} securely: {}",
password_path.display(),
e
))
})?;
file.write_all(password.as_bytes()).map_err(|e| {
BindingCoreError::generic(format!(
"Failed to write password file {}: {}",
password_path.display(),
e
))
})?;
file.sync_all().map_err(|e| {
BindingCoreError::generic(format!(
"Failed to flush password file {}: {}",
password_path.display(),
e
))
})?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;

let permissions = std::fs::Permissions::from_mode(0o600);
fs::set_permissions(&password_path, permissions).map_err(|e| {
BindingCoreError::generic(format!(
Expand Down Expand Up @@ -1449,6 +1499,36 @@ impl AgentWrapper {
Ok(())
}

/// Rotate the agent's cryptographic keys.
///
/// Optionally change the signing algorithm. Uses the full rotation
/// pipeline (journal, save, config re-sign) via `advanced::rotate_with_mutex`.
pub fn rotate_keys(&self, algorithm: Option<&str>) -> BindingResult<String> {
// Resolve config path from the agent's config_dir
let config_path = {
let agent = self.lock()?;
agent
.config
.as_ref()
.and_then(|c| c.config_dir())
.map(|dir| dir.join("jacs.config.json").display().to_string())
};

let result = jacs::simple::advanced::rotate_with_mutex(
&self.inner,
config_path.as_deref(),
algorithm,
)
.map_err(|e| BindingCoreError::generic(format!("Key rotation failed: {}", e)))?;

serde_json::to_string(&result).map_err(|e| {
BindingCoreError::serialization_failed(format!(
"Failed to serialize rotation result: {}",
e
))
})
}

/// Create an ephemeral in-memory agent. No config, no files, no env vars needed.
///
/// Replaces the inner agent with a freshly created ephemeral agent that
Expand Down Expand Up @@ -3170,6 +3250,7 @@ pub use jacs;
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::tempdir;

fn cross_language_fixtures_dir() -> Option<PathBuf> {
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
Expand Down Expand Up @@ -3227,6 +3308,38 @@ mod tests {
assert_eq!(result.signer_id, "some-agent");
}

#[cfg(unix)]
#[test]
fn read_password_file_rejects_insecure_permissions() {
let tmp = tempdir().expect("tempdir");
let password_path = tmp.path().join(".jacs_password");
fs::write(&password_path, "TopSecret!123").expect("write password file");
fs::set_permissions(&password_path, std::fs::Permissions::from_mode(0o644))
.expect("set insecure permissions");

let result = read_password_file(&password_path);
assert!(result.is_err(), "insecure password file should be rejected");
assert!(
result.unwrap_err().message.contains("insecure permissions"),
"error should mention permissions"
);
}

#[cfg(unix)]
#[test]
fn persist_password_file_creates_owner_only_password_file() {
let tmp = tempdir().expect("tempdir");
persist_password_file(tmp.path(), "TopSecret!123").expect("persist password file");

let password_path = tmp.path().join(".jacs_password");
let metadata = fs::metadata(&password_path).expect("stat password file");
let mode = metadata.permissions().mode() & 0o777;

assert_eq!(mode, 0o600, "password file should be created with 0600");
let saved = fs::read_to_string(&password_path).expect("read password file");
assert_eq!(saved, "TopSecret!123");
}

#[test]
#[ignore = "pre-existing: cross-language fixture verification fails with relative parent paths"]
fn verify_standalone_accepts_relative_parent_paths_from_subdir() {
Expand Down
23 changes: 23 additions & 0 deletions binding-core/src/simple_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,29 @@ impl SimpleAgentWrapper {
)
})
}

// =========================================================================
// Key rotation
// =========================================================================

/// Rotate the agent's cryptographic keys.
///
/// Optionally change the signing algorithm. Returns a JSON string of the
/// `RotationResult` (jacs_id, old_version, new_version, key hash, proof).
pub fn rotate_keys(&self, algorithm: Option<&str>) -> BindingResult<String> {
let result = jacs::simple::advanced::rotate(&self.inner, algorithm).map_err(|e| {
BindingCoreError::new(
crate::ErrorKind::Generic,
format!("Key rotation failed: {}", e),
)
})?;
serde_json::to_string(&result).map_err(|e| {
BindingCoreError::new(
crate::ErrorKind::SerializationFailed,
format!("Failed to serialize rotation result: {}", e),
)
})
}
}

// =============================================================================
Expand Down
4 changes: 4 additions & 0 deletions binding-core/tests/doc_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ impl DocumentService for MockDocumentService {
fn set_visibility(&self, _key: &str, _visibility: DocumentVisibility) -> Result<(), JacsError> {
Ok(())
}

fn verify(&self, key: &str) -> Result<(), JacsError> {
self.get(key).map(|_| ())
}
}

// =============================================================================
Expand Down
22 changes: 18 additions & 4 deletions binding-core/tests/fixtures/cli_mcp_alignment.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@
"status": "aligned",
"notes": "Both export an attestation as a DSSE envelope"
},
{
"cli_command": "agent rotate-keys",
"mcp_tool": "jacs_rotate_keys",
"status": "aligned",
"notes": "Both rotate the agent's cryptographic keys with optional algorithm change"
},
{
"cli_command": "a2a assess",
"mcp_tool": "jacs_assess_a2a_agent",
Expand Down Expand Up @@ -163,6 +169,14 @@
{
"cli_command": "convert",
"mcp_excluded_reason": "Format conversion (JSON/YAML/HTML); could be an MCP tool in the future"
},
{
"cli_command": "agent keys-list",
"mcp_excluded_reason": "Filesystem scan for archived keys; CLI-only diagnostic"
},
{
"cli_command": "agent repair",
"mcp_excluded_reason": "Crash recovery; requires filesystem access, CLI-only"
}
],
"cli_only_feature_gated": [
Expand Down Expand Up @@ -352,11 +366,11 @@
}
],
"summary": {
"total_cli_commands": 30,
"total_cli_commands": 33,
"total_cli_feature_gated": 4,
"total_mcp_tools": 42,
"aligned_pairs": 16,
"cli_only_count": 16,
"total_mcp_tools": 43,
"aligned_pairs": 17,
"cli_only_count": 18,
"cli_only_feature_gated_count": 4,
"mcp_only_count": 27,
"mcp_only_intentional": 17,
Expand Down
Loading
Loading