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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. 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]
@@ -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,5 @@
1
+ from harness.security.permissions import PermissionContext
2
+ from harness.security.risk import RiskDecision, classify
3
+ from harness.security.secrets import resolve_secret_ref
4
+
5
+ __all__ = ["PermissionContext", "RiskDecision", "classify", "resolve_secret_ref"]
@@ -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
@@ -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}")
@@ -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."""