ags-cli 0.1.0__py3-none-any.whl
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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
harness/schemas/run.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from harness.models.enums import AdapterKind, RunRole, RunStatus
|
|
10
|
+
from harness.schemas.common import IdModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunStartRequest(BaseModel):
|
|
14
|
+
task_id: uuid.UUID | None = Field(
|
|
15
|
+
default=None, description="Existing task to run; or provide objective inline."
|
|
16
|
+
)
|
|
17
|
+
objective: str | None = None
|
|
18
|
+
workspace: str = "default"
|
|
19
|
+
policy: str = Field(default="single", description="Routing policy name from config.")
|
|
20
|
+
providers: list[str] | None = Field(
|
|
21
|
+
default=None, description="Override provider set for single/fanout (e.g. [mock, local])."
|
|
22
|
+
)
|
|
23
|
+
session_id: uuid.UUID | None = Field(
|
|
24
|
+
default=None, description="Resume/extend an existing session."
|
|
25
|
+
)
|
|
26
|
+
additional_objective: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
description="Per-turn prompt for this run; overrides the session's original "
|
|
29
|
+
"objective as the message while still inheriting shared memory.",
|
|
30
|
+
)
|
|
31
|
+
model: str | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
description="One-off model override for this run (e.g. 'gemini-3.5-flash' or 'llama3.1').",
|
|
34
|
+
)
|
|
35
|
+
models: dict[str, str] | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Per-provider model override keyed by provider name, for fanout "
|
|
38
|
+
"waves where each agent should run on its own model (e.g. "
|
|
39
|
+
"{'claude': 'claude-opus-4-8', 'local': 'llama3.1'}). Takes precedence over "
|
|
40
|
+
"`model` for the providers it names; others fall back to `model`, then to the "
|
|
41
|
+
"provider's configured default.",
|
|
42
|
+
)
|
|
43
|
+
workspace_mode: Literal["main", "worktree"] | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="For a NEW session: 'worktree' isolates it in a private git "
|
|
46
|
+
"worktree on its own branch; 'main' works directly in the project dir. "
|
|
47
|
+
"Defaults to the workspace's default_workspace_mode (worktree).",
|
|
48
|
+
)
|
|
49
|
+
write_mode: bool | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="For a NEW session: True = edit mode (agent may write files), "
|
|
52
|
+
"False = plan/read-only. Omitted (None) inherits the workspace/env default "
|
|
53
|
+
"(plan unless HARNESS_WRITE is set).",
|
|
54
|
+
)
|
|
55
|
+
attachments: list[uuid.UUID] | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="Ids of previously uploaded attachments (POST /attachments) "
|
|
58
|
+
"to include with this turn's message.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunOut(IdModel):
|
|
63
|
+
session_id: uuid.UUID
|
|
64
|
+
adapter_kind: AdapterKind
|
|
65
|
+
role: RunRole
|
|
66
|
+
status: RunStatus
|
|
67
|
+
graph_node_id: str
|
|
68
|
+
error: str | None = None
|
|
69
|
+
cost: dict
|
|
70
|
+
context_meta: dict = {}
|
|
71
|
+
final_output: str | None = None
|
|
72
|
+
started_at: datetime | None = None
|
|
73
|
+
finished_at: datetime | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RunStartResponse(BaseModel):
|
|
77
|
+
session_id: uuid.UUID
|
|
78
|
+
runs: list[RunOut]
|
|
79
|
+
dispatched: bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RunEventOut(BaseModel):
|
|
83
|
+
seq: int
|
|
84
|
+
type: str
|
|
85
|
+
ts: datetime
|
|
86
|
+
payload: dict
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CompareRequest(BaseModel):
|
|
90
|
+
run_ids: list[uuid.UUID]
|
|
91
|
+
strategy: str = Field(default="judge", description="judge | merge | manual")
|
|
92
|
+
judge_provider: str | None = Field(
|
|
93
|
+
default=None,
|
|
94
|
+
description="Provider to use as the judge. Defaults to the first enabled one.",
|
|
95
|
+
)
|
|
96
|
+
judge_model: str | None = Field(
|
|
97
|
+
default=None,
|
|
98
|
+
description="Model override for the judge call; defaults to the judge "
|
|
99
|
+
"provider's configured model.",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MergeRequest(BaseModel):
|
|
104
|
+
run_ids: list[uuid.UUID]
|
|
105
|
+
persist: bool = Field(default=True, description="Persist merged result as workspace memory.")
|
|
106
|
+
merged_content: str | None = Field(
|
|
107
|
+
default=None, description="Manual merge text; if omitted a deterministic merge is built."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ComparisonOut(IdModel):
|
|
112
|
+
session_id: uuid.UUID
|
|
113
|
+
run_ids: list[uuid.UUID]
|
|
114
|
+
strategy: str
|
|
115
|
+
result: dict
|
|
116
|
+
canonical_memory_item_id: uuid.UUID | None = None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from harness.models.enums import SessionStatus
|
|
8
|
+
from harness.schemas.common import IdModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionOut(IdModel):
|
|
12
|
+
workspace_id: uuid.UUID
|
|
13
|
+
task_id: uuid.UUID | None = None
|
|
14
|
+
parent_session_id: uuid.UUID | None = None
|
|
15
|
+
canonical_objective: str
|
|
16
|
+
constraints: dict
|
|
17
|
+
settings: dict = {}
|
|
18
|
+
summary: str
|
|
19
|
+
status: SessionStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionResumeRequest(BaseModel):
|
|
23
|
+
policy: str = "single"
|
|
24
|
+
providers: list[str] | None = None
|
|
25
|
+
additional_objective: str | None = None
|
|
26
|
+
model: str | None = None
|
|
27
|
+
models: dict[str, str] | None = None
|
|
28
|
+
attachments: list[uuid.UUID] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SessionSettingsUpdate(BaseModel):
|
|
32
|
+
"""PUT /sessions/{id}/settings body. ``write_mode`` explicit null clears the
|
|
33
|
+
session override back to inherit; omitting the field leaves it unchanged
|
|
34
|
+
(distinguished via ``model_fields_set``)."""
|
|
35
|
+
|
|
36
|
+
write_mode: bool | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionModeOut(BaseModel):
|
|
40
|
+
write_mode: bool | None = None
|
|
41
|
+
workspace_write_mode: bool
|
|
42
|
+
effective_write_mode: bool
|
|
43
|
+
mode: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionWorkspaceOut(BaseModel):
|
|
47
|
+
"""Isolation state of a session's private workspace (worktree)."""
|
|
48
|
+
|
|
49
|
+
workspace_mode: str
|
|
50
|
+
path: str | None = None
|
|
51
|
+
branch: str | None = None
|
|
52
|
+
base: str | None = None
|
|
53
|
+
exists: bool = False
|
|
54
|
+
dirty: bool = False
|
|
55
|
+
changed_files: list[str] = []
|
|
56
|
+
ahead: int = 0
|
|
57
|
+
behind: int = 0
|
|
58
|
+
merged_at: str | None = None
|
|
59
|
+
merge_commit: str | None = None
|
|
60
|
+
fallback_reason: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SessionMergeRequest(BaseModel):
|
|
64
|
+
squash: bool = False
|
|
65
|
+
delete_worktree: bool = False
|
|
66
|
+
message: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SessionMergeResponse(BaseModel):
|
|
70
|
+
merged: bool
|
|
71
|
+
nothing_to_merge: bool = False
|
|
72
|
+
commit: str | None = None
|
|
73
|
+
conflicts: list[str] = []
|
|
74
|
+
reason: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SessionBranchRequest(BaseModel):
|
|
78
|
+
note: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SessionClearRequest(BaseModel):
|
|
82
|
+
all: bool = False
|
|
83
|
+
status: SessionStatus | None = None
|
|
84
|
+
older_than_days: int | None = None
|
|
85
|
+
workspace: str | None = None
|
|
86
|
+
ids: list[uuid.UUID] | None = None
|
|
87
|
+
dry_run: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SessionClearResponse(BaseModel):
|
|
91
|
+
count: int
|
|
92
|
+
deleted: bool
|
|
93
|
+
sessions: list[SessionOut]
|
harness/schemas/task.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from harness.models.enums import TaskStatus
|
|
8
|
+
from harness.schemas.common import IdModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskCreate(BaseModel):
|
|
12
|
+
workspace: str = Field(default="default", description="Workspace slug.")
|
|
13
|
+
title: str
|
|
14
|
+
objective: str
|
|
15
|
+
constraints: dict = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskOut(IdModel):
|
|
19
|
+
workspace_id: uuid.UUID
|
|
20
|
+
title: str
|
|
21
|
+
objective: str
|
|
22
|
+
constraints: dict
|
|
23
|
+
status: TaskStatus
|
|
24
|
+
created_by: str
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Approval gate policy: decides whether a proposed tool call must pause for a
|
|
2
|
+
human decision before it executes, layered on top of the risk classification
|
|
3
|
+
in ``harness.security.risk``.
|
|
4
|
+
|
|
5
|
+
``risk.classify`` maps an abstract action (e.g. ``shell.exec``) to a risk class
|
|
6
|
+
and says whether that CLASS of action needs approval under the workspace's
|
|
7
|
+
trust policy. This module adds a second, narrower layer: even an action class
|
|
8
|
+
that requires approval can be auto-approved when its concrete invocation
|
|
9
|
+
matches a conservative allowlist of known-harmless commands (``pytest``,
|
|
10
|
+
``git status``, ...) — so routine dev workflows don't grind to a halt waiting
|
|
11
|
+
on a human for every shell call, while anything else (most notably destructive
|
|
12
|
+
commands like ``rm -rf``) still pauses for approval.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from harness.models.enums import ApprovalActionClass, TrustPolicy
|
|
21
|
+
from harness.security.risk import classify
|
|
22
|
+
|
|
23
|
+
# Tool name (as offered by harness.tools.fs_tools.tool_specs) -> abstract risk
|
|
24
|
+
# action understood by harness.security.risk.ACTION_RISK. Tools with no entry
|
|
25
|
+
# here are never gated (read_file/list_dir/grep, and writes — already confined
|
|
26
|
+
# to the workspace root, so never "outside allowlist").
|
|
27
|
+
TOOL_ACTION: dict[str, str] = {
|
|
28
|
+
"execute_command": "shell.exec",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Command prefixes safe enough to auto-approve even though shell.exec is
|
|
32
|
+
# normally gated. Anchored at the start of the (stripped) command so
|
|
33
|
+
# `pytest -k foo` matches but `rm pytest_leftover.txt` doesn't. Keep this list
|
|
34
|
+
# to read-only / side-effect-free operations.
|
|
35
|
+
AUTO_APPROVE_PATTERNS: tuple[str, ...] = (
|
|
36
|
+
r"pytest\b",
|
|
37
|
+
r"python -m pytest\b",
|
|
38
|
+
r"npm (test|run test|run lint|run build|run typecheck)\b",
|
|
39
|
+
r"yarn (test|lint|build)\b",
|
|
40
|
+
r"pnpm (test|lint|build)\b",
|
|
41
|
+
r"(git|hg) (status|log|diff|show|branch)\b",
|
|
42
|
+
r"ls\b",
|
|
43
|
+
r"cat\b",
|
|
44
|
+
r"pwd\b",
|
|
45
|
+
r"echo\b",
|
|
46
|
+
r"mypy\b",
|
|
47
|
+
r"ruff\b",
|
|
48
|
+
r"tsc\b",
|
|
49
|
+
r"grep\b",
|
|
50
|
+
)
|
|
51
|
+
_AUTO_APPROVE_RE = re.compile(r"^\s*(" + "|".join(AUTO_APPROVE_PATTERNS) + r")")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class GateDecision:
|
|
56
|
+
tool_name: str
|
|
57
|
+
action: str | None
|
|
58
|
+
action_class: ApprovalActionClass | None
|
|
59
|
+
requires_approval: bool
|
|
60
|
+
reason: str
|
|
61
|
+
auto_approved: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _auto_approved(action: str, args: dict) -> bool:
|
|
65
|
+
if action != "shell.exec":
|
|
66
|
+
return False
|
|
67
|
+
return bool(_AUTO_APPROVE_RE.match(str(args.get("command", ""))))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def evaluate_gate(
|
|
71
|
+
tool_name: str,
|
|
72
|
+
args: dict,
|
|
73
|
+
*,
|
|
74
|
+
trust_policy: TrustPolicy = TrustPolicy.restricted,
|
|
75
|
+
) -> GateDecision:
|
|
76
|
+
"""Decide whether *tool_name* (called with *args*) may run immediately or
|
|
77
|
+
must pause for a human approval."""
|
|
78
|
+
action = TOOL_ACTION.get(tool_name)
|
|
79
|
+
if action is None:
|
|
80
|
+
return GateDecision(tool_name, None, None, False, "unclassified/low-risk tool")
|
|
81
|
+
|
|
82
|
+
decision = classify(action, trust_policy=trust_policy)
|
|
83
|
+
if not decision.requires_approval:
|
|
84
|
+
return GateDecision(tool_name, action, decision.action_class, False, decision.reason)
|
|
85
|
+
|
|
86
|
+
if _auto_approved(action, args):
|
|
87
|
+
return GateDecision(
|
|
88
|
+
tool_name, action, decision.action_class, False,
|
|
89
|
+
f"{decision.reason}; auto-approved by allowlist", auto_approved=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return GateDecision(tool_name, action, decision.action_class, True, decision.reason)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ApprovalRequired(Exception):
|
|
96
|
+
"""Raised by a tool executor when a gated action needs a human decision
|
|
97
|
+
before it may run. Carries enough to record an Approval and, once granted,
|
|
98
|
+
re-execute the exact same call."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, tool_name: str, args: dict, decision: GateDecision) -> None:
|
|
101
|
+
# NOTE: BaseException.args is a reserved attribute (Exception.__init__
|
|
102
|
+
# overwrites it) — the tool's call arguments are stored as tool_args to
|
|
103
|
+
# avoid silently clobbering the standard exception message tuple.
|
|
104
|
+
super().__init__(f"approval required for {tool_name!r}: {decision.reason}")
|
|
105
|
+
self.tool_name = tool_name
|
|
106
|
+
self.tool_args = args
|
|
107
|
+
self.decision = decision
|
harness/security/auth.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Authentication abstraction. ``LocalPasswordAuth`` is the local-first default;
|
|
2
|
+
``OIDCAuth`` is an OIDC-ready stub (env-configured issuer/client) completed when a
|
|
3
|
+
self-hosted GUI needs SSO. The API depends on the ``AuthProvider`` protocol only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
import bcrypt
|
|
12
|
+
import jwt
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from harness.settings import Settings, get_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _hash_password(password: str) -> bytes:
|
|
19
|
+
# bcrypt's input limit is 72 bytes; truncate defensively (passwords longer
|
|
20
|
+
# than that gain no additional entropy under bcrypt).
|
|
21
|
+
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _verify_password(password: str, hashed: bytes) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
return bcrypt.checkpw(password.encode()[:72], hashed)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Principal(BaseModel):
|
|
32
|
+
subject: str
|
|
33
|
+
roles: list[str] = ["user"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthProvider(Protocol):
|
|
37
|
+
def authenticate(self, username: str, password: str) -> Principal | None: ...
|
|
38
|
+
def issue_token(self, principal: Principal) -> str: ...
|
|
39
|
+
def verify_token(self, token: str) -> Principal | None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LocalPasswordAuth:
|
|
43
|
+
"""Single-admin local auth backed by env-configured credentials. Tokens are
|
|
44
|
+
HS256 JWTs signed with ``HARNESS_AUTH_SECRET``."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, settings: Settings) -> None:
|
|
47
|
+
self._settings = settings
|
|
48
|
+
self._admin_user = settings.local_admin_user
|
|
49
|
+
# Hash the configured admin password at startup; never store plaintext.
|
|
50
|
+
self._admin_hash = _hash_password(settings.local_admin_password)
|
|
51
|
+
|
|
52
|
+
def authenticate(self, username: str, password: str) -> Principal | None:
|
|
53
|
+
if username == self._admin_user and _verify_password(password, self._admin_hash):
|
|
54
|
+
return Principal(subject=username, roles=["admin", "user"])
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def issue_token(self, principal: Principal) -> str:
|
|
58
|
+
payload = {
|
|
59
|
+
"sub": principal.subject,
|
|
60
|
+
"roles": principal.roles,
|
|
61
|
+
"iat": int(time.time()),
|
|
62
|
+
"exp": int(time.time()) + 86400,
|
|
63
|
+
}
|
|
64
|
+
return jwt.encode(payload, self._settings.auth_secret, algorithm="HS256")
|
|
65
|
+
|
|
66
|
+
def verify_token(self, token: str) -> Principal | None:
|
|
67
|
+
try:
|
|
68
|
+
data = jwt.decode(token, self._settings.auth_secret, algorithms=["HS256"])
|
|
69
|
+
except jwt.PyJWTError:
|
|
70
|
+
return None
|
|
71
|
+
return Principal(subject=data["sub"], roles=data.get("roles", ["user"]))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OIDCAuth:
|
|
75
|
+
"""OIDC-ready stub. Validates RS256 tokens against a configured issuer's JWKS.
|
|
76
|
+
Completed when SSO is enabled; raises clearly until configured."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, settings: Settings) -> None:
|
|
79
|
+
self._settings = settings
|
|
80
|
+
|
|
81
|
+
def authenticate(self, username: str, password: str) -> Principal | None:
|
|
82
|
+
raise NotImplementedError(
|
|
83
|
+
"OIDC uses an external authorization-code flow, not password auth."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def issue_token(self, principal: Principal) -> str: # pragma: no cover
|
|
87
|
+
raise NotImplementedError("Tokens are issued by the OIDC provider.")
|
|
88
|
+
|
|
89
|
+
def verify_token(self, token: str) -> Principal | None:
|
|
90
|
+
# Planned: fetch JWKS from {issuer}/.well-known, validate RS256, map claims.
|
|
91
|
+
raise NotImplementedError("OIDC verification is not yet implemented.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_provider_cache: AuthProvider | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_auth_provider(settings: Settings | None = None) -> AuthProvider:
|
|
98
|
+
global _provider_cache
|
|
99
|
+
if _provider_cache is not None and settings is None:
|
|
100
|
+
return _provider_cache
|
|
101
|
+
settings = settings or get_settings()
|
|
102
|
+
provider: AuthProvider = (
|
|
103
|
+
OIDCAuth(settings) if settings.auth_provider == "oidc" else LocalPasswordAuth(settings)
|
|
104
|
+
)
|
|
105
|
+
_provider_cache = provider
|
|
106
|
+
return provider
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Permission enforcement: filesystem allowlists, command gates, egress policy,
|
|
2
|
+
and the global read-only / dry-run switches. Safe-by-default — anything not
|
|
3
|
+
explicitly allowed is denied."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from harness.settings import get_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_mode_enabled() -> bool:
|
|
15
|
+
"""Whether the operator opted this process into letting CLI agents EDIT files
|
|
16
|
+
(set by exporting ``HARNESS_WRITE=1`` before starting ``ags serve``). Off by
|
|
17
|
+
default: agents run read-only / plan mode unless this is explicitly set."""
|
|
18
|
+
return os.environ.get("HARNESS_WRITE", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PermissionContext:
|
|
23
|
+
"""Effective permissions for a run, composed from global settings + workspace
|
|
24
|
+
policy. ``read_only`` blocks all writes; ``dry_run`` lets the graph execute but
|
|
25
|
+
skips side effects."""
|
|
26
|
+
|
|
27
|
+
fs_allowlist: list[str] = field(default_factory=list)
|
|
28
|
+
allow_network_egress: bool = False
|
|
29
|
+
read_only: bool = False
|
|
30
|
+
dry_run: bool = False
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_settings(cls, fs_allowlist: list[str] | None = None) -> PermissionContext:
|
|
34
|
+
s = get_settings()
|
|
35
|
+
return cls(
|
|
36
|
+
fs_allowlist=fs_allowlist or [],
|
|
37
|
+
read_only=s.read_only,
|
|
38
|
+
dry_run=s.dry_run,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def path_allowed(self, path: str | os.PathLike[str]) -> bool:
|
|
42
|
+
target = Path(path).resolve()
|
|
43
|
+
for root in self.fs_allowlist:
|
|
44
|
+
try:
|
|
45
|
+
target.relative_to(Path(root).resolve())
|
|
46
|
+
return True
|
|
47
|
+
except ValueError:
|
|
48
|
+
continue
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def assert_write_allowed(self, path: str | os.PathLike[str]) -> None:
|
|
52
|
+
if self.read_only:
|
|
53
|
+
raise PermissionError("read-only mode: writes are disabled")
|
|
54
|
+
if not self.path_allowed(path):
|
|
55
|
+
raise PermissionError(f"path outside filesystem allowlist: {path}")
|
|
56
|
+
|
|
57
|
+
def assert_egress_allowed(self, url: str) -> None:
|
|
58
|
+
if not self.allow_network_egress:
|
|
59
|
+
raise PermissionError(f"network egress denied by policy: {url}")
|
harness/security/risk.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""High-risk action classification. Maps a proposed action to a risk class and
|
|
2
|
+
decides whether a human approval gate is required, given workspace trust policy.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from harness.models.enums import ApprovalActionClass, TrustPolicy
|
|
10
|
+
|
|
11
|
+
# Action verbs the orchestrator/adapters may emit, mapped to a risk class.
|
|
12
|
+
ACTION_RISK: dict[str, ApprovalActionClass] = {
|
|
13
|
+
"shell.exec": ApprovalActionClass.command,
|
|
14
|
+
"fs.write_outside_allowlist": ApprovalActionClass.high_risk,
|
|
15
|
+
"net.egress": ApprovalActionClass.egress,
|
|
16
|
+
"secret.read": ApprovalActionClass.secret,
|
|
17
|
+
# Calling an external MCP tool can reach the network / external systems; treat it
|
|
18
|
+
# as egress so trust-policy gating and (once wired) the approval gate cover it.
|
|
19
|
+
"mcp.tool_call": ApprovalActionClass.egress,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Actions that always require approval regardless of trust policy.
|
|
23
|
+
ALWAYS_REQUIRE_APPROVAL = {"shell.exec", "net.egress", "secret.read"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RiskDecision:
|
|
28
|
+
action: str
|
|
29
|
+
action_class: ApprovalActionClass | None
|
|
30
|
+
requires_approval: bool
|
|
31
|
+
reason: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def classify(
|
|
35
|
+
action: str,
|
|
36
|
+
*,
|
|
37
|
+
trust_policy: TrustPolicy = TrustPolicy.restricted,
|
|
38
|
+
require_approval_for: set[str] | None = None,
|
|
39
|
+
) -> RiskDecision:
|
|
40
|
+
require_approval_for = require_approval_for or ALWAYS_REQUIRE_APPROVAL
|
|
41
|
+
action_class = ACTION_RISK.get(action)
|
|
42
|
+
|
|
43
|
+
if action_class is None:
|
|
44
|
+
return RiskDecision(action, None, False, "unclassified/low-risk action")
|
|
45
|
+
|
|
46
|
+
# local_only workspaces are the most conservative.
|
|
47
|
+
requires = action in require_approval_for or action in ALWAYS_REQUIRE_APPROVAL
|
|
48
|
+
if trust_policy == TrustPolicy.local_only and action_class in {
|
|
49
|
+
ApprovalActionClass.egress,
|
|
50
|
+
ApprovalActionClass.secret,
|
|
51
|
+
}:
|
|
52
|
+
requires = True
|
|
53
|
+
|
|
54
|
+
return RiskDecision(
|
|
55
|
+
action=action,
|
|
56
|
+
action_class=action_class,
|
|
57
|
+
requires_approval=requires,
|
|
58
|
+
reason=f"classified as {action_class.value} under {trust_policy.value} trust",
|
|
59
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Secret resolution. Secrets are read from the environment (or Docker secrets
|
|
2
|
+
mounted as files) at runtime and held only in process memory. They are NEVER
|
|
3
|
+
written to the database, memory items, or transcripts.
|
|
4
|
+
|
|
5
|
+
``secret_ref`` grammar:
|
|
6
|
+
env:NAME -> os.environ["NAME"]
|
|
7
|
+
file:/path -> contents of /path (Docker secret mount)
|
|
8
|
+
(empty/None) -> None
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SECRETS_DIR = Path.home() / ".ags" / "secrets"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_secret_ref(ref: str | None) -> str | None:
|
|
21
|
+
if not ref:
|
|
22
|
+
return None
|
|
23
|
+
scheme, _, value = ref.partition(":")
|
|
24
|
+
if scheme == "env":
|
|
25
|
+
return os.environ.get(value) or None
|
|
26
|
+
if scheme == "file":
|
|
27
|
+
p = Path(value)
|
|
28
|
+
return p.read_text().strip() if p.exists() else None
|
|
29
|
+
# Unknown scheme: treat as a literal env var name for convenience, but warn-safe.
|
|
30
|
+
return os.environ.get(ref) or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_secret_file(name: str, value: str) -> str:
|
|
34
|
+
"""Persist a secret to ``~/.ags/secrets/<name>`` (0600) and return its ``file:``
|
|
35
|
+
ref. Used so a GUI-added provider's API key lives on disk as a file secret —
|
|
36
|
+
never in the database, memory, or transcripts."""
|
|
37
|
+
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
with contextlib.suppress(OSError):
|
|
39
|
+
os.chmod(SECRETS_DIR, 0o700)
|
|
40
|
+
path = SECRETS_DIR / name
|
|
41
|
+
path.write_text(value)
|
|
42
|
+
with contextlib.suppress(OSError):
|
|
43
|
+
os.chmod(path, 0o600)
|
|
44
|
+
return f"file:{path}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delete_secret_file(name: str) -> None:
|
|
48
|
+
with contextlib.suppress(FileNotFoundError):
|
|
49
|
+
(SECRETS_DIR / name).unlink()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service layer: orchestrates models + repositories behind the API and CLI."""
|